Compare commits

...

230 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
Nick Fitzpatrick
ee29a82916 remount EditorBubbleMenu on hide (#77575) 2026-07-14 01:30:18 +05:30
Tyler Plude
ffdd62caaa Disable harbormaster metabot feedback & add appdb MCP feedback (#76932)
* disable metabot feedback harbormaster; add appdb persistence for mcp feedback

* fix kondo

* re-add metabot enabled check; fix migration

* update to v64
2026-07-13 12:59:27 -07:00
Nick Fitzpatrick
6e6bca18d0 Make getState generic for createThunkAction (#77615) 2026-07-14 01:26:49 +05:30
technomancy
5a6a2d84e2 Use qualified db names for locks in snowflake. (#77548)
* Use qualified db names for locks in snowflake.

We're seeing some timeouts in Snowflake on CI. There are a few reasons
for this, but part of it is that we are locking a lot waiting for
access to the datasets. These locks are currently based on unqualified
database names, not hashed and branch-qualified, so they lock more
often than they need to. Using the qualified names spreads things out
a bit.

In order to do this, we need to change the `dataset-lock` method to
take the whole dataset (to hash it) not just the dataset's database-name.

* Fix upload test to not assume table set is stable.

The purpose of this test is to ensure that the created table is gone,
but the assertion it makes is far too wide. This breaks in snowflake
where we have other tests randomly creating tables in the shared cloud
DB across jobs.
2026-07-13 11:44:02 -07:00
Alexander Polyankin
669dc959ff Remove unused code (#77613)
* Remove unused code

* Remove unused code
2026-07-13 14:38:16 -04:00
Ethan McCue
3554cc36fb onboarding: add Github username to team, add Github -> Slack ID mapping (#77608)
Update JSON files
2026-07-13 14:31:21 -04:00
Andrei Chernykh
5340d25eea Convert enterprise auth session middleware to TypeScript (#77399)
* Convert enterprise auth session middleware to TypeScript

* Fix sessionMiddlware -> sessionMiddleware typo

* Justify getState type cast in application permissions reducer
2026-07-13 10:42:03 -07:00
Arik Aronov
7a39244afe Show redirect destination on the OAuth consent page (#77578)
redirect dest in concent page + test for 3 cases
2026-07-13 20:02:33 +03:00
Chris Truter
f9dce94f95 Omit empty optional fields when serializing a transform index request (#77594) 2026-07-13 16:26:53 +00:00
Riley Thompson
2d7ed54076 Finish mbql5 compilation for more drivers (#76549)
* mysql mbql5

* clickhouse mbql5

* starburst mbql5

* sqlite mbql5

* presto-jdbc mbql5

* druid-jdbc mbql5

* vertica mbql5

* athena mbql5

* hive-like mbql5

* fix databricks
2026-07-13 12:20:57 -04:00
bryan
f4d3f69010 Native 2FA: EE scaffold, TOTP core, two-step login, recovery codes (#77449) 2026-07-13 16:15:58 +00:00
Andrei Chernykh
88c9058a51 Convert snippets and application_permissions plugin modules to TypeScript (#77398)
* Rename snippets and application_permissions plugin modules to TS extensions

* Convert snippets and application_permissions plugin modules to TypeScript
2026-07-13 08:44:34 -07:00
Kamil Mielnik
bd2df5059b GDGT-2770 RFC 147. Require justification for type casts (#77168) 2026-07-13 22:06:32 +07:00
Ngoc Khuat
4a12356ec8 Add Snowplow tracking for Index Manager (#77600) 2026-07-13 22:03:52 +07:00
Chris Truter
8e13d485ec Allow MCP to create text cards and headings on dashboards (#77362) 2026-07-13 14:55:37 +02:00
Chris Truter
4d849db7f8 Force a full rebuild when a transform index request is :failed (#77593) 2026-07-13 18:29:38 +07:00
Nicolò Pretto
5a024bd2ff chore(modules): fix embedding violations and start enforcing most of the modules (#77517)
* [pr-3] Move SDK error types to embedding-sdk-shared

* [pr-3] Move SDK auth-config types to embedding-sdk-shared

* remove un-useful usage of SdkCollectionId to remove import of sdk code in core app

we don't really need that type and they both end up being `number | string`

* [pr-3] Move PLUGIN_NOTIFICATIONS_SDK to the core app plugins

I don't particularly like this: this is one of those "plugins" used
both to activate enterprise code and to inject behavior from the SDK
into the core app. We don't have an alternative at the moment, so this
is the change with the smallest blast radius. The plugin was already
used in the core app, so this just moves it to the right place.

* [pr-3] Move onEditQuestion from the SDK dashboard context to DashboardContext

DashCardMenuItems (feature/dashboard) reached into the SDK bundle's
SdkDashboardContext just for the onEditQuestion override.
I moved the onEditQuestion to the DashboardContext instead, as
it's used in the core app dashboard.

* [pr-3] Remove CommonSdkStoryWrapper from SimpleDataPicker stories

It was not needed

* [pr-3] Move useLazySelector to the SDK package

The hook exists to read the lazy-loaded bundle's redux store from the
npm package, so the package is its natural home. Its only bundle-side
consumer, useInitDataInternal, already receives the redux store as a
parameter and now subscribes to it directly instead of going through
the window props-store singleton.

* [pr-3] Introduce SdkSharedStoreState

Some core app code currently needs to read _one_ piece of information
from the SDK redux state, I moved that piece of info into a new type
called `SdkSharedState` which I documented as being something we
should avoid adding to in the future.

* [pr-3] Promote embedding-sdk-bundle to the app tier

Promotes the sdk bundle to app tier, it needs a special rule for the
bridge
module that we'll hopefully clean up in the future

* [pr-4] Promote embedding-iframe-sdk to the app tier

EAJS becomes app tier, but we need to whitelist the theme preview from
importing it

* [pr-5] Move DashboardSharingEmbeddingModal to the dashboard module

The modal is dashboard UI living under metabase/embedding, giving the
embedding module an outgoing edge into dashboard (it imports
metabase/dashboard/selectors). Move it next to its domain so the next
commit can enforce the embedding module's outgoing boundaries.

* [pr-5] Move UserForm and group helpers to the common module

UserForm is used by the new "invite people to view" modal
(InviteToViewModal, which lives in metabase/embedding, shared tier),
so it moves out of feature/admin down to metabase/common together with
GroupsMultiSelect and the group utilities it depends on. Import paths
updated, no logic changes.

* [pr-5] Move DashCardMenuItem to the embedding-sdk plugins contract

It's imported by shared/embedding-sdk but it lived in feature/dashboard,
moved it down to remove violation that will be enforced on one of the next
commits.

* [pr-5] Enforce outgoing boundaries for shared/embedding and shared/embedding-sdk
2026-07-13 12:56:31 +02:00
Ngoc Khuat
fe4a72d76f Record model views in the view log (#77091) 2026-07-13 09:11:59 +00:00
Nicolò Pretto
31e79dc02d create new embedding modules - first pr (#77263)
* [pr-1] Define embedding modules

Defines the embedding modules without promoting them yet: modules whose
back-edges still need cleanup stay at their current tier (shared, outgoing
unenforced). Each module is then promoted to app in its own follow-up PR,
together with the fixes its back-edges require.

* [pr-1] Promote embedding-sdk-package to the app tier

The npm package is an entrypoint and should be able to import from
anything, and nothing should import from it.
I raised it up as app tier

* [pr-1] Promote embedding-sdk-ee to the app tier

This should probably be "embedding-features" in the future, but for now
I'm moving it to app to be above enterprise (which it imports from) and
to prohibit other things from importing from it

* [pr-2] Move LegacyStaticEmbeddingModalProps to the plugins contract

Module `embedding-iframe-sdk-setup` will be moved to app tier, so we move
`LegacyStaticEmbeddingModalProps` to the oss plugin file to avoid an import
going up to app

* [pr-2] Promote embedding-iframe-sdk-setup to the app tier

`embedding-iframe-sdk-setup` imports EAJS, which is app tier
so this also needs to be brought up to app tier
TBD how we want to structure this, but it kinda makes sense that some
parts of the core app are above EAJS as they use it (we also use it
to render theme previews in the admin settings for themes)
2026-07-13 09:28:59 +02:00
Chris Truter
64dfbf0bb3 Dedup the modules config.edn writer (#77571) 2026-07-13 08:32:30 +02:00
Romeo Van Snick
9a6f10f9dc Replace redux-auth-wrapper with guard components (#77259)
* Replace redux-auth-wrapper guards with facade guard components

* Convert admin route guards to facade guard components

* Full-page redirect for absolute post-login redirect targets

* Normalize post-login redirect target to an absolute URL

* Convert transforms route guard to facade guard component
2026-07-13 15:44:24 +10:00
Romeo Van Snick
1ad3f2b114 Move onEnter/onChange/onLeave to components and effects (#77264)
* Move root route onEnter/onChange/onLeave into components and effects

* Replace data-studio index onEnter redirect with a component

* Address review: settle LoadCurrentUser on rejection, drop test any-cast

* Point test Route import at the metabase/router facade

* Freeze has-user-setup on entry so the setup wizard is not interrupted

* Colocate route lifecycle components with the modules that own them

* Drop onEnter/onChange/onLeave from the facade's Route props
2026-07-13 14:47:04 +10:00
Tyler Plude
98424e1a61 [BOT-1807] Add metabot treemap chart support (#77568)
* add treemap chart support

* fix fe lint
2026-07-12 21:31:42 -07:00
Romeo Van Snick
4d47327087 Fix forbidden react-router-redux import in transforms route-guards (#77581)
Use metabase/router routerActions in transforms route-guards
2026-07-13 12:33:32 +10:00
Romeo Van Snick
29993aa6db Typescript 7 (#73979)
* Update to typescript 7.0 (beta)

* Switch to tsgo in scripts

* Remove NODE_OPTIONS from typecheck script

* Use typescript 7 instead of native-preview, but keep 6 around for lint

* Use typescipt 7.0.2

* Use real typescript 6.0.3 for eslint, run tsc 7 by explicit path

* Fix type errors surfaced by typescript 7
2026-07-13 08:41:36 +10:00
Alexander Polyankin
733e1c6f3e Change defaults for graalvm (#77573) 2026-07-11 18:22:31 -04:00
Alexander Polyankin
97f82c0792 mb-sql-parsing-mode=python (#77558)
* refactor(sql-parsing): protocol + graal split, render.js-style context pool

Mirror the static-viz renderer structure (channel/render/js) for the sqlglot
parser:

- protocol.clj: strongly-typed SqlParser protocol for the sql_tools operations
- graal.clj (replaces pool.clj): GraalPy implementation on a 0..2 dirigiste
  pool; contexts share one refcounted Engine and one bootstrap Source, created
  with the first context and closed with the last, so an idle pool releases
  all native memory (replacing the old TTL/min-size-1 leak mitigation);
  timed-out contexts are interrupted and disposed instead of released
- common.clj: create-pool copied as-is from render.js.common (replaces the
  PythonEval protocol); the future external-python impl will share it
- core.clj: public API unchanged; keeps JVM-side pre/post-processing
  (VALUES stripping, field/error conversion) and delegates through the
  protocol

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

* tune(sql-parsing, static-viz): 0..3 pooled graal contexts, 10-minute idle reap

:idle-minutes is now an explicit create-pool option in both pool factories
(no default): graal pools (sqlglot + static-viz) keep idle workers for up to
10 minutes, the node renderer pool stays at 1.

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

* feat(sql-parsing): native CPython parser, mirroring the static-viz node renderer

- python.clj: :python SqlParser running sqlglot in a pool of external CPython
  child processes speaking newline-delimited JSON over stdin/stdout
  (sql_tools_cli.py); in dev the child imports straight from
  resources/python-sources, from the jar the sources are written to a fresh
  temp zip per spawn (zipimport) and deleted with the process; single-process
  pool (0..1, 1-minute idle reap), timeout kills a wedged process, one retry
  when an acquired process turns out dead, call-level Python errors release
  the healthy process back
- settings.clj + parser.clj: sql-parsing-mode setting (:graalvm default |
  :python) and the mode-selection namespace, like static-viz-mode/renderer
- common.clj: python-sources locations + dev sqlglot install move here from
  graal.clj (shared by both transports), plus make-parser building the
  protocol reify over a transport call fn (marshalling written once)
- fix latent nil-schema bug in validate-query arg encoding: (json/encode
  (or schema "{}")) double-encoded, making Python treat the string "{}"
  as a strict-mode schema; encode an empty map instead

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

* fix(sql-parsing): transport-agnostic parse errors; 10-minute idle for process pools

Both parser transports now throw the same ex-info for Python-side failures
(common/call-failed-ex: :sql-parsing/error + extracted Python error type) —
graal wraps PolyglotException, the CPython transport wraps its ok:false
responses — and core exposes parse-error? so callers stop catching
PolyglotException (sql-tools.sqlglot updated; a python-mode instance
previously bypassed its parse-error fallbacks entirely).

Also raise the external-process pools (sql-parsing python, static-viz node)
to a 10-minute idle reap, matching the graal context pools.

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

* test(sql-parsing): un-nest testing blocks

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

* build(docker): install python3 for the :python sql-parsing mode

Both images already carry nodejs for the :node static-viz renderer; add
python3 (~45MB on alpine) so MB_SQL_PARSING_MODE=python works out of the box.

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

* build(docker): default sql-parsing to python and static-viz to node in the entrypoint

The image ships python3 and node, so prefer the external-process backends
(lighter and faster than in-process GraalVM) unless MB_SQL_PARSING_MODE /
MB_STATIC_VIZ_MODE are set explicitly, following the MB_JETTY_HOST pattern.

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

* tune(static-viz): keep the node process pool at a 1-minute idle reap

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

* fix(sql-parsing): wire settings into module init

metabase.core.init-test requires every .settings namespace to be reachable
from an init namespace; add metabase.sql-parsing.init and register it.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 16:16:44 -04:00
Chris Truter
6188ef843f Resolve the default search engine from capability precedence (#77418)
* Resolve the default search engine from capability precedence

The search-engine setting's :appdb default meant the engine precedence
list never chose semantic, no matter how many of its dependencies were
met. Engine selection now works in three layers:

- supported-engine? is a pure capability check (app-db type, premium
  features, infrastructure) and no longer reads the search-engine
  setting.
- The default engine is the configured override when servable,
  otherwise the first supported engine in the precedence
  (semantic > appdb > in-place). Unknown configured values warn once
  and degrade instead of breaking all of search.
- Indexes are maintained for the default engine, the engines it
  declares as dependencies (semantic mixes in appdb results), and any
  engines opted in via the new additional-search-engines setting.

Reading the search-engine setting now resolves the engine that
actually serves search, so the admin UI needs no changes; the raw
override is available via configured-search-engine.

An explicitly passed per-request search_engine that cannot serve now
returns a 400 naming the cause (unknown / not supported / not enabled)
instead of silently falling back; legacy engine cookies keep the
fallback. The engine-active prometheus metric now reports active-set
membership rather than capability.

MB_SEMANTIC_SEARCH_ENABLED remains a kill switch, folded into the one
canonical semantic availability predicate (so repair/cleanup tasks now
respect it too); it will be removed once Cloud stops setting it.

Part of BOT-1832

* Tighten search engine docstrings and comments

* Validate cookie-carried search engines before use

A stale engine cookie naming an unmaintained index (e.g. appdb on an
in-place-default instance) now degrades to the default engine instead
of querying a missing index. The in-place exemption in engine-active?
uses = rather than isa?, so derived engines don't inherit a free pass.

Updates the prometheus search-engine-metrics-test to the new
engine-active semantics.

Part of BOT-1832

* Serve per-request engines through validated query-params; gate semantic writers on activity

Review follow-ups from roborev job 2600 and the workflow review:

- The engine middleware now reads and writes :query-params - the
  :params injection had been dead since the api.macros migration, so
  engine cookies had no effect on the served engine. Cookie values are
  validated (stale/unknown degrade to the default), and param values
  are normalized: blank means absent, the qualified form the API
  returns in :engine is accepted, and the legacy fulltext name maps to
  appdb at the boundary so activity checks use exact matches.
- engine-status in search.engine is the one classification of whether
  an engine can serve (:ok/:unknown/:unsupported/:inactive); the API
  layer maps it to 400s and no longer re-encodes engine invariants.
- Semantic fallback (EE core and the metabot search tool) prefers
  engines with a maintained index over merely supported ones, so an
  in-place default with additional-search-engines=semantic mixes with
  the warm appdb index instead of in-place.
- The semantic indexer and index-repair tasks gate on the new
  semantic-search-active? instead of availability, so a supported but
  inactive semantic index no longer accrues hourly embedding spend.
- active-engines guards the pre-init nil default and only
  capability-checks dependency entries.

Part of BOT-1832

* Canonicalize legacy engine names at every boundary; tolerate repeated engine params

search.engine/canonical-engine resolves legacy names (fulltext -> appdb)
for configured values, additional-search-engines, and request
params/cookies alike, so active-engine membership compares exactly.

A repeated search_engine query param parses as a vector; the middleware
now passes it through for schema validation to reject with a 400
instead of throwing.

Part of BOT-1832

* Review cleanups: single evaluation of engine resolution values

Bind configured-engine, the parsed param engine, and active-engines
once per expression instead of re-evaluating (Copilot); remove blank
lines inside the check-engine-serves! case form (CodeRabbit).

Part of BOT-1832

* Engine-active metric means can-serve; harden param parsing and dependency filtering

engine-active shares engine-status's definition: in-place is always
active while it exists, indexed engines while their index is
maintained. Fixes the prometheus test assertion that compared a metric
to itself.

param->engine strips the search.engine/ prefix rather than splitting on
slashes, so junk values 400 with the full value named instead of
validating their last segment, and degenerate values like // cannot
produce an empty or nil keyword.

active-engines filters dependency engines through known-engine? before
supported-engine?, matching the startup-ordering guard in
supported-engines.

Part of BOT-1832

* Reject malformed explicit engine values instead of treating them as absent

A non-blank search_engine that strips to a blank suffix (e.g.
search.engine/) parses to an unknown engine and 400s naming the raw
value; only genuinely blank values fall through to the cookie/default.

Part of BOT-1832

* Clarify the unknown-engine filter comment in supported-engines

* Fix review findings: runtime kill switch, csv hygiene, cookie unpinning, shared fallback

- Semantic Quartz jobs are scheduled when the instance is capable
  (pgvector + feature) and check availability/activity per execution,
  so the kill switch works at runtime again instead of gating job
  scheduling at boot.
- semantic-search-active? is plain active-set membership; the HNSW
  build gates on it like every other index writer.
- additional-search-engines values are trimmed, blanks dropped, and
  qualified/legacy names canonicalized (prefix handling now lives in
  canonical-engine, the one boundary fn).
- An explicit blank search_engine clears the engine cookie (the unpin
  mechanism this branch had removed) instead of being ignored.
- fallback-engine lives in search.engine; the EE semantic core and the
  metabot search tool share it.
- The runtime-activation backfill note moved from the
  additional-search-engines description to a comment.

Part of BOT-1832

* Initialize the semantic index from repair when none is active

Semantic can become active at runtime (kill switch re-enabled, or added
to additional-search-engines) without init! ever having run; the hourly
repair task now initializes and backfills in that case instead of
no-oping until a manual /re-init.

Also renames warn-once to warn-once! per the side-effect convention and
adds end-to-end coverage for the pinned-cookie and blank-unpin paths.

Part of BOT-1832

* Cover the repair-initializes path; unpin test asserts the resolved default

The blank-unpin api test compared against a hard-coded appdb default,
which does not hold on MySQL/MariaDB app dbs. New repair test starts
from a blank pgvector db and asserts repair-index! creates an active
index and gates the documents.

Part of BOT-1832

* Trigger search index init when additional engines are set at runtime

Registers the (previously dangling) init-job-key as a durable,
trigger-less quartz job wrapping the search init, and the
additional-search-engines setter fires it when it exists. In tests the
scheduler is not running, so the setter stays side-effect free there;
env-var configuration is covered by the startup init.

Part of BOT-1832

* Rename the engine cookie and cover the pin/unpin round trip

metabase.SEARCH_ENGINE becomes metabase.SEARCH_ENGINE_OVERRIDE, so the
population of long-lived cookies written before engine validation
existed stays inert.

The round-trip test covers pinning via an explicit param, serving from
the cookie, and clearing it with a blank param.

Part of BOT-1832

* Order semantic capability gates cheapest first

The license check short-circuits before the pgvector URL check, whose
slot becomes a DB probe under BOT-1796.

Part of BOT-1832

* Only trigger search init when the setting change activates an engine

Semantic init re-gates every searchable document, so an unconditional
trigger made any write to additional-search-engines cost a full app-db
sweep. The setter now diffs active-engines across the set and triggers
only for newly activated engines.

Part of BOT-1832

* Defer the engine-activation trigger to after commit

The activation diff and quartz trigger run via do-after-commit in a
future (extracted to trigger-init-for-newly-active-engines!), observing
the final committed settings state - a surrounding set-many! may
include the setting that makes the engine supported - without I/O on
the transaction's thread.

The cookie round-trip test uses the in-process client so the handler
runs inside the fixture's engine and index bindings.

Part of BOT-1832

* Review nits: log async trigger failures, quote the cookie regex, fix a docstring

The post-commit init trigger logs exceptions instead of dropping them
in the future; the cookie test quotes the cookie name before building
its regex; build-hnsw-index-async! says active, matching its gate.

Part of BOT-1832

* Schedule semantic jobs on the boot-static gate; indexer initializes missing indexes

Job scheduling happens once at startup, so it now gates on the only
boot-static input: whether a pgvector DB is configured. The runtime
gates (license, kill switch, engine activity) are checked per
execution, so applying a license or re-enabling the kill switch at
runtime no longer strands semantic as an unindexed default until
restart.

The indexer's 20-second tick initializes the index when it is missing,
so every runtime activation path (license, kill switch, setter on a
stale cluster node) converges within seconds instead of waiting for the
hourly repair or a restart.

Also: the blank-param unpin deletes the engine cookie (max-age 0)
instead of writing an empty 20-year one; the activation trigger helper
lives in search.task.search-index with static requires; the setter test
uses poll-until for the async positive case and a direct synchronous
call for the negative.

Part of BOT-1832

* Runtime self-heal initializes all active engines and the HNSW index

The indexer's missing-index branch runs the generic search init, which
covers newly active dependencies, and applies the same HNSW-strategy
safety net as task registration.

Part of BOT-1832

* Ensure the HNSW index on the indexer's active path

An index-backed strategy configured while semantic was inactive misses
the setter's build event; the indexer tick now fires the async build
when the active index lacks its HNSW index. The pg_indexes existence
check is self-limiting: CONCURRENTLY registers the index when the build
starts.

Part of BOT-1832

* Fix engine tests on app dbs without appdb search support

Explicitly requesting an unservable engine now 400s instead of silently
falling back, so tests must not hard-code appdb: the data-studio search
tests iterate only supported engines, the cookie staleness test pins
in-place (servable everywhere), and the not-enabled case runs only
where appdb is supported.

Also realigns warn-once! call args (cljfmt).

Part of BOT-1832

* Keep semantic active under the search test fixtures

active-engines now derives from the default engine, so fixtures that
pin default-engine were silently deactivating semantic: semantic
queries in the metabot search tool fell through to the term engine.
Both fixtures pin active-engines to the set the old derivation gave
them - appdb plus semantic when supported, or semantic alone for
legacy search.

Part of BOT-1832

* Rename with-new-search-* test fixtures to with-appdb-search-*

The appdb engine predates semantic search; new no longer disambiguates.

Part of BOT-1832

* Update the last supported-implies-active consumers

The metabot semantic-search feature predicate gates tool schemas on
engine-status :ok rather than capability, so semantic_queries is only
advertised when a semantic index is actually maintained.

The metric collector runs only when semantic is active, and index
cleanup no-ops until the bookkeeping tables exist: available but never
initialized is now a steady state, and both jobs would otherwise warn
or throw about missing tables on every run. Cleanup still runs while
merely available so an opted-out instance's leftover indexes get
dropped.

Part of BOT-1832

---------

Co-authored-by: Tyler Plude <tsplude@gmail.com>
2026-07-10 20:16:56 -07:00
Romeo Van Snick
544a0c2ba8 Migrate query_builder/analytics to TypeScript (#76934)
* Migrate query_builder/analytics to TypeScript

* Type Card.creationType and drop analytics cast

Address review: type Card.creationType as a union instead of a plain
string, narrow Question.creationType(), and make the new_question_saved
type field optional/nullable to match the Snowplow schema. Removes the
cast in the analytics tracker.

* Drop as const in load-question via Card annotation
2026-07-11 09:33:43 +10:00
Romeo Van Snick
836e12b526 Re-own react-router-redux behind the metabase/router facade (#77142)
* Re-own react-router-redux mechanisms behind the metabase/router facade

* Wire store, app and store types onto the re-owned router facade

* Re-point react-router-redux call sites onto metabase/router

* Update router tests and harness for the re-owned facade

* Ban react-router-redux imports outside metabase/router

* Model routing state's null location contract instead of casting

* Flatten CallHistoryMethodAction into per-method history action types

* Match history actions with ts-pattern in routerMiddleware

* Preserve explicit null location in createMockRoutingState
2026-07-11 09:33:29 +10:00
Ben Grabow
ba83271710 Auto-fix modules config.edn (#77541)
* Auto-fix modules config.edn

* Extract generic nrepl integration wrapper

* Remind claude to use this

* Demo the module config fixer with some tests
2026-07-10 17:20:33 -05:00
dpsutton
6b855cbe81 feat(dev): browser-based email template previewer (#77546)
Adds `bun run email-dev`: a small Bun server + static page that renders
the Handlebars email templates in src/metabase/channel/email/ with
editable sample data, so designers and copywriters can iterate on email
copy and layout without running Metabase or sending real emails.

- live-reloads when a .hbs file changes on disk (1s polling)
- Fields/JSON panels to fill in template values; edits persist in
  localStorage per browser
- Desktop/Narrow/Phone width toggles and Copy HTML
- sample-contexts.js mirrors the real send-time contexts (traced from
  messages.clj and the notification handlers)
- mirrors the jknack custom helpers (equals, format-date, now, initials,
  card-url, dashboard-url, trash-url) in handlebars.js

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:09:04 -05:00
Andrei Chernykh
b8673198cd Migrate Link, ListItem, and LoadingSpinner from Emotion to Mantine + CSS modules (#77323)
* Migrate Link, ListItem, and LoadingSpinner from Emotion to Mantine + CSS modules

* Fix TableList disabled-state assertions by exposing data-disabled instead of relying on Emotion-injected styles
2026-07-10 13:47:48 -07:00
lbrdnk
87f0514519 Mention archival and soft deletion in MCP metric update API (#77525)
* Mention archival and soft deletion in MCP metric update api

* Update src/metabase/agent_api/api.clj

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>

* Update src/metabase/agent_api/api.clj

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>

* Extend update-metric-archive-test

---------

Co-authored-by: Chris Truter <crisptrutski@users.noreply.github.com>
2026-07-10 22:43:57 +02:00
Nemanja Glumac
dea12ca75c Wire the test-driver action to work with the Quarantine switch (#77476) 2026-07-10 21:12:56 +02:00
Nemanja Glumac
71863190b1 Wire the quarantine switch into the embedding e2e workflows (#77518) 2026-07-10 19:07:34 +02:00
Eric Normand
5a4f82063e Name the referring entity in remote sync not-found errors (#77430)
* Name the referring entity in remote sync not-found errors

When an import references content that doesn't exist on this instance, the
error named only the missing entity's raw serdes id. It now also names the
entity holding the dangling reference, and backtick-quotes names and ids so
leading and trailing whitespace is visible.

`load-one!` takes the referrer as an explicit parameter rather than carrying
it on the load context: `load-deps!` reduces over sibling deps and each
recursive `load-one!` returns its own context, so a context key would leave
later siblings labelled with the deepest child's referrer.

Message construction lives in `missing-reference-message` so the format can
change without touching the throw sites.

* Attach the referrer by catch-and-rethrow instead of threading it

Replaces the extra `load-one!`/`load-deps!` parameter with a try/catch around
the dependency load. The nearest enclosing `load-one!` unwinds first, so it is
the frame that attaches itself as the referrer; outer frames see the key
already present and rethrow unchanged. The original exception is kept as the
cause, preserving the stack trace of the detection site.

Adds an end-to-end assertion in bare-import-test that a real import of a Card
referencing a missing Database carries the Card as `:referrer`, which the
hand-built ex-info unit tests could not cover.

* Key the database-not-found error off ex-data, and name the referring card

`source-error-message` detected this case by matching "database not found"
against the cause's message text, then interpolated that raw internal string
into the user-facing error:

  Import failed: A referenced database does not exist on this instance.
  table id present, but database not found: [clickhouse nil some_table]

`import-table-fk` now tags the throw with `:error ::database-not-found` and
`:db-name`, and the render walks the whole cause chain looking for that key
rather than checking only the immediate cause. `::load-failure` carries the
referring entity, so both missing-content paths now render through
`missing-reference-message`:

  Import failed: Card `Some card` (`abc123`) references Database (`clickhouse`),
  which does not exist on this instance. ...

Also fixes `:database-names` in that ex-data, which selected names from
:model/Table rather than :model/Database. It has no consumers.

The end-to-end test uses a result_metadata `:table_id`, since that is the path
that actually reaches `import-table-fk` with an undeclared database:
`result-metadata-deps` derives deps only from `:field_ref`, and a Card's own
top-level `table_id` is recomputed from `dataset_query` by
`populate-query-fields` on insert.

* Backtick-quote file paths in remote sync ingest-error messages

GHY-3992 asks for content names to be quoted so leading and trailing
whitespace is visible. The ::not-found path already did this; the
ingest-errors branch still joined raw paths, and the message already uses
": " between path and reason and "; " between entries, so a path containing
punctuation or trailing space was ambiguous to read.

No fallback is added for a nil :file. All three ingest-error producers
(ingest.clj file-hierarchy!, ingestable.clj read and parse failures) always
set it, and only YamlIngestion and IngestableSnapshot produce a non-empty
ingest-errors; the other Ingestables delegate.

Splits source-error-message-entity-not-found-test into three deftests, one
per error branch, which kondo requires now that each has grown assertions.

* Render load-failure errors with the entity name, reason, and stripped keys

::load-failure fell through to the generic "Failed to reload from git
repository: <ex-message>" fallback, showing a NanoID and burying the
stripped-keys note. It now reads:

  Import failed: could not save Card `Orders by Month` (`abc123`).
  NOT NULL constraint failed: report_card.display.

and, when the entity was part of a dependency loop, adds "It may have been
saved without: `dashcards`, `parameters`." — a partial row for it survived
the import, which the user previously had no way to learn.

Renames the ::load-failure ex-data key `:referrer` to `:entity`. It names the
entity that failed to load, which is a referrer only relative to the missing
database in the ::database-not-found case; that branch now reads `:entity` and
passes it as the referrer. ::not-found keeps `:referrer`, where it really is
the parent.

The new branch is ordered after the tenant-collection and database-not-found
branches, both of which match on causes that a ::load-failure wraps.

::circular is left on the fallback. Rendering its cycle needs an ordered
expansion chain — `expanding` is a set — and it only fires when the
key-stripping recovery has already failed.

* Address CodeRabbit review

- Remove a duplicated `testing` block in impl_test.clj. It was byte-identical
  to the one above it, introduced when the deftest was split. Replaced with a
  doseq covering trailing, leading, and no surrounding whitespace.
- Mark `missing-reference-message` and `load-failure-message` private; neither
  is used outside impl.clj.
- Capture the exception once in bare-import-test's not-found case instead of
  running the same failing import twice, and flatten the resulting nested let.
2026-07-10 16:24:16 +00:00
Riley Thompson
9197dc9057 Fix path in location-connection-properties (#77322)
fix path
2026-07-10 14:53:47 +00:00
Sébastien
0b9e1c0be4 Show the SQL editor when useMetabot opens a native question (#77370) 2026-07-10 16:18:42 +02:00
github-automation-metabase
46d217cf64 Auto-update documentation for master (#77469) 2026-07-10 09:23:39 -04:00
Chris Truter
f9ecaf7f92 Fix weak assertion in create-table-from-schema!-test (#77520) 2026-07-10 13:13:42 +00:00
Chris Truter
ae56aaf2b2 Fix the shadow-cljs warning from lib.core's macro re-export (#77521) 2026-07-10 13:08:42 +00:00
Egor Grushin
ab9a3e191a GDGT-2766 GDGT-2766 Migrates deprecated FormTextInput and FormInput components (#77161)
* GDGT-2766 GDGT-2766 Migrates deprecated FormTextInput and FormInput components

* GDGT-2780-2766 DRY FormInputWidget

* GDGT-2780-2766 fix lost margin

* replace with new FormField

* fix marging

* rollback be.visible to exist in action e2e specs

* replace HTML_INPUT_TYPES map with inline ts-match

* fix margin
2026-07-10 13:54:37 +01:00
Sébastien
9f42bcf323 Apply global dashboardCardMenu plugin from MetabaseProvider to dashboards (#77500) 2026-07-10 14:28:51 +02:00
Chris Truter
130888462d Fix the metabot semantic-search tests not runing in CI (#77519) 2026-07-10 11:18:03 +00:00
Ngoc Khuat
32a869b1e6 Index manager (#75848) 2026-07-10 11:05:07 +00:00
Alexander Polyankin
f7f8697947 Add tests for the node static-viz renderer (#77508) 2026-07-10 03:41:13 -04:00
Timothy S. Dean
960d036afd Bump js-yaml to 4.3.0 (#77459)
Bump js-yaml to 4.3.0
2026-07-10 00:49:07 -06:00
da
60e1b995d3 Add CSV uploads to Snowflake (#77239)
Co-authored-by: Chris Truter <chris@metabase.com>
2026-07-10 04:18:36 +00:00
Sébastien
15195d006a fix(sdk-bundle): restore back button after "Edit question" in embedded dashboards (#77389) 2026-07-10 06:14:14 +02:00
appleby
5bd7fa6247 Support OpenRouter (#77139)
* Fix metabase.metabot.api/openrouter-model-group

Fallback to parsing the id if the display_name has no ":"-separated prefix.

* Add a default OpenRouter model so connect-without-model works

Without an entry in default-llm-metabot-model-by-provider, a PUT /api/metabot/settings that connects openrouter with
only an API key would save the key but silently fail to switch llm-metabot-provider.

* Use real dotted OpenRouter model IDs in defaults and docs

OpenRouter model IDs use dots in version numbers, e.g. anthropic/claude-haiku-4.5. The hyphenated
anthropic/claude-haiku-4-5 referenced by the default args and docstrings does not exist.

* FE: enable openrouter provider in metabot admin UI config

* Whitelist openrouter models

Filter openrouter list-models down to the same curated Anthropic and OpenAI model set the direct providers support,
instead of returning the full ~340-model catalog.

* Throw an exception if callers pass :ai-proxy true for openrouter

Similar to bedrock and openai, supporting this would require work on the ai-service side, and we already reject
non-anthropic providers in validate-metabase-managed-provider!

* Remove explicit credentials check in list-all-models

We want to allow callers to omit the creds and fallback to the configured api key from settings. core/resolve-auth
still handles the auth check and will throw if no credentials are provided or configured.

* Support prompt caching for openrouter

Report `:cacheCreationTokens` and `:cacheReadTokens` in the `:usage`.

For anthropic models, add the `:cache_control` breakpoint on the system message to enable caching (openai caching is
automatic).

* Apply code review suggestion in AIProviderSettingsSection.unit.spec.tsx

Co-authored-by: Maksym Yakubych <undrfined@gmail.com>

* Add gpt-5.6 and drop gpt-5 from openrouter supported-models

See also #77450

* Apply code review suggestion in fe tests

Co-authored-by: Maksym Yakubych <undrfined@gmail.com>

---------

Co-authored-by: Maksym Yakubych <undrfined@gmail.com>
2026-07-10 00:47:30 +00:00
Alexander Polyankin
6eff4789f0 Static viz node renderer (#77419) 2026-07-09 20:16:11 -04:00
Egor Grushin
fe6bc8a408 Gdgt 2587 2581 replace deprecated numericinput components (#77117)
* GDGT-2587 GDGT-2581 migrate deprecated components

* GDGT-2587 GDGT-2581 get rid of deprecated components

* GDGT-2581 fix tests

* fix tests

* GDGT-2584 DRY FormNumberInputWidget

* GDGT-2587-2581 fix floats + cover the change with a test

* GDGT-2587-2581 fix a test

* GDGT-2587-2581 addressing PR's comments

* GDGT-2587-2581 small UX improvement

* GDGT-2587-2581 stronger assertions in model-actions test

* GDGT-2587-2581 remove spreading

* GDGT-2587-2581 scrollIntoView before visibility asserts

* GDGT-2587-2581 assert exist not visible for constraint errors
2026-07-10 01:00:25 +01:00
Timothy S. Dean
117020fc2e Pin linkify-it to 5.0.2 (#77457)
Pin linkify-it to 5.0.2
2026-07-09 17:06:06 -06:00
Egor Grushin
b9bba24cfd GDGT-2584 migrate FormTextArea (#77089)
* GDGT-2584 migrate FormTextArea

* GDGT-2584 remove unused code

* GDGT-2581 fix error labeling

* GDGT-2584 DRYed FormTextareaWidget

* GDGT-2584 make textarea resizeable for the snippet form

* GDGT-2584 restore actions

* GDGT-2584 fix margin

* GDGT-2584 fix margins
2026-07-09 23:27:35 +01:00
appleby
a817ed88a6 Add gpt-5.6 to openai supported-models (#77450)
* Add gpt-5.6 to openai supported-models

* Drop gpt-5 from openai supported-models
2026-07-09 22:21:36 +00:00
Eric Normand
0a3932e9cf Move remote-sync branch switching into Settings with destructive-action guard rails (GHY-4019) (#77286)
* Move remote-sync branch switching into Settings with guard rails (GHY-4019)

Branch switching ran a full import that hard-deletes any synced-collection
content absent from the target branch, and it lived as a routine app-bar
dropdown item. A forced switch could permanently delete never-pushed content.

Backend:
- spec: detect collection-scoped deletion conflicts (Cards/metrics, Dashboards,
  Documents, Timelines), not just the transform family; wire into get-conflicts
- import: expose force_deletion on POST /import so a forced version/dirty
  override no longer silently forces deletions
- async-import dirty guard now returns the dirty objects (what would be lost)
- PUT /settings rejects a read-write branch switch (must use the guarded import);
  read-only changes and first-time config still allowed

Frontend:
- remove the Switch branch affordance from the app-bar sync controls (branch
  name stays as a read-only label; push/pull intact); drop the switch-branch
  conflict variant
- add a guarded branch switcher to the read-write Settings panel: destructive
  warning copy, hard block when there are unsaved changes (lists them, no force
  path), destructive confirm when clean, stale-tab handling

* Refine Settings branch switcher: choice modal, admin gating, direct clean switch

Follow-up to the branch-switching rework, from review feedback:

- Offer a choice modal (push / new branch / discard) when switching with
  unsaved changes, instead of a hard block — restores the switch-branch
  conflict variant, now driven from the Settings panel BranchSwitcher.
- Drop the destructive confirmation on a clean switch: with everything pushed
  the switch is reversible, so it runs directly.
- Show a red "N unsaved change(s)" label beside the dropdown when dirty.
- Gate the switcher on superuser: non-admins (e.g. settings managers) see the
  section with an "admin required" message instead of a control they can't use;
  the section no longer depends on the admin-only branch setting being readable.
- Match the section subtitle styling to the other settings sections.
- Strengthen the import superuser test to cover a branch switch.

* Update remote-sync E2E for Settings-panel branch switching

Branch switching moved from the app bar to the Settings panel, so the
branching specs and helpers drive it there now:

- Replace the app-bar switch helpers with Settings-panel ones
  (visitRemoteSyncSettings, getSettingsBranchSwitcher,
  createBranchViaSettings, switchBranchViaSettings).
- Rewrite the branching tests to create/switch branches on the Settings
  page, navigating back to the app for content changes.
- Switching with unsaved changes opens the choice modal (wait for the
  unsaved-changes indicator first); a clean switch goes directly.
- Snowplow triggered_from: app-bar -> admin-settings.

* cljfmt

* Address review: guard-bypass fix, anti-join, shared removal predicate

- api: block blanking the branch in read-write mode too, so it can't be used to
  reset the branch-switch guard and then switch freely; regression test added.
- spec: compute collection deletion conflicts with a SQL anti-join + count/limit
  instead of loading every candidate and an exists? per row, so it scales when a
  switch would drop a large synced collection.
- Extract spec/removal-where-clauses as the single definition of what an import
  reconcile removes for a model; remove-unsynced! and the deletion-conflict check
  both build their WHERE from it (removes the duplicate build-entity-id-where-clause).
- Add a parity test asserting the conflict warning is exactly the unsynced subset
  of what remove-unsynced! removes.
- Nits: be.visible for the dirty-warning indicator; wrap a long test string.

* Build the deletion-conflict WHERE with a thread-first instead of nested into/conj

* Give check-deletion-conflicts the same anti-join as the collection check

Was doing a t2/exists? per candidate (and materializing every candidate).
Now counts via one SQL anti-join, sharing removal-where-clauses and the new
model-id-column / unsynced-anti-join helpers with check-collection-deletion-conflicts.
Adds a direct spec-level test for it.

* Build collection deletion conflicts with cond-> instead of if/into

* Cover NativeQuerySnippet in the deletion-conflict warning

Generalize check-collection-deletion-conflicts -> check-content-deletion-conflicts:
warn before an import deletes any unsynced entity-id content, not just collection-
scoped models. This pulls in NativeQuerySnippet (global, no collection scope), which
otherwise fell through both conflict checks and could be silently deleted on a forced
import/branch switch. Collection stays excluded (structural; its contents are covered).
Adds a snippet test case.

* Address branch-switch review: drop force_deletion param, refetch dirty at switch, add tests

- Remove the unused force_deletion param from POST /import (schema, destructure,
  and async-import! call); internal force-deletion? threading is unchanged.
- BranchSwitcher refetches dirty state at switch time so a stale settings-page
  snapshot can't route a switch down the wrong (delete-without-warning) path.
- Add BranchSwitcher unit tests: non-admin message, dirty count label,
  clean direct switch, and the choose-what-to-do modal on the dirty path.

* Left-align the branch value in the settings switcher for consistency with other selects

* Show a 'Using <env var>' note when the sync branch is pinned by an env var

* Fix branch-switcher e2e: scroll the switcher into view, drop spurious branch-switched event

The Sync branch section sits below the fold at the default viewport, and Cypress treats an element
clipped by a scrollable ancestor as hidden, so the switcher assertions failed (surfaced by
testing-library as a misleading "Unable to find an element"). Scroll to it before asserting.

BranchSwitcher also reported a branch switch when creating a branch, emitting a spurious
remote_sync_branch_switched alongside the branch picker's remote_sync_branch_created. Creating a
branch forks the current one without a reconcile, so only the creation is reported now.
2026-07-09 22:15:44 +00:00
Nemanja Glumac
44dc41a7fa Introduce the Quarantine Control Knob (#77417)
Introduces a `QUARANTINE_ENGINE` variable that acts as a control knob. Either Trunk enforces a quarantine or CI conductor does. So far wired only in three places:
1. fe
2. be
3. e2e
2026-07-09 21:34:00 +00:00
Ben Grabow
f0ef171d25 Ghy-4069 minimal migration approach (#77297)
* Upgrade H2 -> SQLite sample DB by mutating database :driver
2026-07-09 16:29:28 -05:00
Chris Vandoodewaard
5373a65352 Revert "Update translations 2026-07-07" (#77456)
Revert "Update translations 2026-07-07 (#77151)"

This reverts commit 5f56057bfe.
2026-07-09 21:27:36 +00:00
Chris Vandoodewaard
a169d4ab75 remove experimental linear-release-sync workflow (#77302)
* remove experimental linear-release-sync workflow

* remove linear-release-lifecycle job from release workflow

* also cleanup helper functions
2026-07-09 16:47:05 -04:00
Timofey Kachalov
31a0379d5d Update jackson.* dependencies to 2.21.5 (#77427) 2026-07-09 23:55:33 +04:00
Eric Normand
950f453535 Preserve native model column types during serialization (GHY-4043) (#77405)
* Preserve native model column types during serialization (GHY-4043)

export-result-metadata checked (model? card) before the native branch, so
native models were routed through lib/model-preserved-keys — a metadata-merge
soft-key set that omits :base_type/:effective_type. On a read-only Remote Sync
target the column types could not be re-derived without executing the SQL, so
computed columns (e.g. STRING_AGG(...) AS names) fell back to type/* and their
filter UI collapsed to Is empty / Not empty.

Reorder the cond so native cards — models included — go through the branch that
preserves structural type info; only MBQL models, whose types are re-derivable
from the query at import, use the soft-key set.

* Update native-model extract test for preserved column types

extract-result-metadata-native-model-test asserted the old behavior — that a
native model dropped :base_type on export. Update it to expect structural type
info to survive, matching the GHY-4043 fix. Drop the now-unused clojure.set
require.
2026-07-09 19:31:44 +00:00
Kevin Kelleher
7a20b77fbd Add enable/disable setting for Transforms in Data Studio (#75710)
* transform enable/disable setting

* fix test, change approach for self hosted

* fix tests

* fix tests, self review

* add tests

* fix ci

* fix lint

* address review comments

* address review comments

* fix ci

* changelog 63 -> 64

* fix lint, test

* another lint
2026-07-09 19:29:55 +00:00
Maksym Yakubych
7d1f271280 fix: move data complexity lower on the stats page (#77003)
Closes [BOT-1797: Move data complexity lower on the stats page](https://linear.app/metabase/issue/BOT-1797/move-data-complexity-lower-on-the-stats-page)

### Description

Move data complexity lower on the stats page, decrease font size and copy length.

### How to verify

1. Go to Admin settings -> AI.
2. Scroll down to the data complexity section.

### Demo

<img width="1840" height="1120" alt="image" src="https://github.com/user-attachments/assets/627935fd-e9d2-40d6-8c1c-7b2a52c35394" />

### Checklist

- [x] Tests have been added/updated to cover changes in this PR
- [x] If adding new Loki tests: they pass [stress testing](https://github.com/metabase/metabase/actions/workflows/loki-stress-test-flake-fix.yml)
2026-07-09 16:14:13 -03:00
appleby
a9b9b7bb59 Ensure openai reports :cacheReadTokens in :usage (#77341)
* Ensure openai reports `:cacheReadTokens` in `:usage`

Add `openai-usage->aisdk-usage` to convert openai usage format to aisdk format and report `:cacheReadTokens`.

* Add undocumented :cache_write_tokens as well
2026-07-09 19:08:11 +00:00
Eric Normand
368ed03818 Preserve MySQL role name case when expanding role grants (#77415)
`parse-grant` lowercased role names parsed out of `SHOW GRANTS FOR <user>`.
Those names are fed back into `SHOW GRANTS FOR <user> USING <roles>`, and
MySQL 8 treats backticked role identifiers as case-sensitive, so any role
with uppercase characters made the follow-up query fail with error 3530 and
took the whole metadata sync step down with it.

Lowercasing is still correct for the privilege types in the sibling
`:privileges` branch; it was never right for role identifiers.

Also add the missing space before `USING` in the follow-up query, which
only parsed because the user identifier ends in a backtick.
2026-07-09 13:42:32 -05:00
Alexander Polyankin
ffbddbbc6f Static-viz: dispose ECharts instances (OOM fix) + faster SVG dominant-baseline patch (#77412)
* Static-viz: dispose ECharts SSR instances + faster SVG dominant-baseline patch

Two isolated, output-preserving fixes to the static-viz render path:

- Dispose the ECharts/zrender SSR instance after renderToSVGString() in every
  chart component. These instances were never released, leaking ~MBs per render
  and OOMing a long-lived renderer process; memory is flat after.

- Replace patchDominantBaseline's full hast parse+serialize round-trip over the
  whole SVG with a targeted string transform (like the sibling
  transformSvgForOutline), cutting a large share of big-chart render time with
  byte-identical output.

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

* Update BoxPlotChart.tsx

* Update ComboChart.tsx

* Update PieChart.tsx

* Update SankeyChart.tsx

* Update ScatterPlot.tsx

* Update TreemapChart.tsx

* Update WaterfallChart.tsx

* Drop hast deps entirely; svg test uses DOMParser

The SVG dominant-baseline patch no longer uses hast, and its test was the last
user of hast-util-from-html. Rewrite the test with the jsdom DOMParser and
remove hast-util-from-html and hast-util-to-html from the deps.

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

* Fix formatting in ComboChart.tsx

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:27:18 -04:00
Chris Truter
c6a0c72b01 Enforce the 50 MB CSV upload limit on the backend (#77363) 2026-07-09 17:54:36 +00:00
Alexander Polyankin
22725a83b9 Extract a StaticVizRenderer protocol for static-viz rendering (#77315) 2026-07-09 11:26:13 -04:00
Kamil Mielnik
a9e1b0edf7 GDGT-2454 Badge (#74714) 2026-07-09 21:47:43 +07:00
Sloan Sparger
6912a50e85 [Metabot] Chat internal format improvements (#77301)
* [Metabot] Migrate BE to v2 message format (#75672)

* migrate codebase to writing v2 format

* move to camelCase to match upstream schema changes

* improvements

* improvements

* correction

* metadata corrections

* handle string outputs correctly

* put add error code to message metadata in finish message

* self-review pass

* record step start to db

* correct issue in claude provider that sent too long of message ids

* record text state to open up future ability to write in progress work

* increase malli usage

* limit client streamed output for tool calls

* emit cache token usage correctly

* ensure started before error is emited

* update migration version

* [Metabot] FE read new message format (#76429)

* initial impl

* self review

* update e2e tests

* copilot feedback

* pr feedback

* fix lint

* feedback - test helper ergonomics

* misc. fixes to metabot chat improvements integration branch (#77321)

* fixes due to stale branches, correct data- prefix not getting recorded, remove unused code

* clean up debug visuals

* ci fixes

* fix couple more tests

* pr feedback
2026-07-09 09:39:59 -05:00
github-automation-metabase
5f56057bfe Update translations 2026-07-07 (#77151)
* Update translation sources 2026-07-07

* Update translations

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Ryan Laurie <30528226+iethree@users.noreply.github.com>
Co-authored-by: Chris Vandoodewaard <chris.vandoodewaard@metabase.com>
2026-07-09 14:35:20 +00:00
Nicola Mometto
e5bd3a34eb fix: enumerate cols to fix sql server (#77373)
* fix: enumerate cols to fix sql server

* fix sqlserver boolean coercion properly
2026-07-09 14:06:15 +00:00
Chris Truter
6097aa447b Publish / unpublish tables trigger hooks properly (#77374) 2026-07-09 12:22:58 +00:00
Mahatthana (Kelvin) Nomsawadi
ff2b145e37 Forward query edits from viz settings in SDK QuestionSettings (#77262)
* test: add repro for SDK viz-settings column edits being dropped

Reproduces EMB-2057 — in modular embedding (embed.js), removing a
column via Visualization settings > Add or remove columns silently
no-ops. Fails against the current QuestionSettings.tsx onChange,
which drops the second question argument carrying the query edit.

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

* fix: forward query edits from viz settings in SDK QuestionSettings

QuestionSettings.tsx's onChange dropped the second `question`
argument that ChartSettings forwards for query-level edits (e.g.
add/remove columns), applying only the settings to the stale closure
question and never re-running the query. Column-removal edits landed
in the query object but the table kept rendering the old result.

Forward `nextQuestion` when present and re-run only in that case, so
pure viz-settings-only edits (no query change) keep their existing
no-run behavior.

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

* Fix code format

* move test to where it belongs

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 08:47:59 +01:00
Alexander Polyankin
21f2c65b15 Collapse uniform blocked view-data table permissions to db-level rows (#77130) 2026-07-08 21:44:07 -04:00
technomancy
1c90ab2c31 Split databricks CI jobs in 2 parts. (#77312)
Currently they are timing out with some regularity in CI. We've done
this same thing to bigquery and redshift already.

Not only will this get us pass/fail results more quickly; it also
reduces the scope of failures so retries don't have to redo as much
wasted work.
2026-07-09 01:32:33 +00:00
Denis Berezin
82b65c8496 UXW-4481 Timeline Events new UI (#76381)
* UXW-4481 Timeline Events new UI

* Add full implementation (without new icons)

* Update icons

* Self review

* Self review

* Review fixes

* Add focused list of events in sidebar

* Clean-up

* Fix hover color

* Fix unit test

* Better sidebar logic

* Fix e2e

* Review fixes

* Review fixes

* Fix e2e

* Fixes

* Review fix

---------

Co-authored-by: Andrei Chernykh <andrei.chernykh@metabase.com>
2026-07-09 03:59:30 +03:00
Egor Grushin
da4e98d389 GDGT-2576 migrate Toggle (#77063)
* GDGT-2576 migrate Toggle

* GDGT-2576 tests fix

* GDGT-2576 remove unused code + fix test

* GDGT-2576 fix failing tests

* GDGT-2576 drop unused onChange from FormToggle
2026-07-09 01:34:59 +01:00
Fraser
4701e5f8dc Remove querying e2e tests made redundant by backend test parity 2026-07-09 12:12:05 +12:00
Andrei Chernykh
18f5a74df7 Reject replacing a dashcard's card with a question internal to another dashboard (#77236) 2026-07-08 15:20:08 -07:00
Michael Eby
339d2c91b9 docs: add page for treemap visualizations (#77185)
* docs: add page for treemap visualizations

* Optimized 1 images, saved 237.8 KB (44.8% smaller)

* fix broken link

* Edits from Jeff

* Edits from Robert

* Edits from Jeff 2

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-07-08 16:21:36 -04:00
Ben Grabow
51e50d157c Avoid loading any H2 classes until/unless the H2 lib is on the classpath (#76842)
* Avoid loading any H2 classes until/unless the H2 lib is on the classpath
2026-07-08 14:15:03 -05:00
technomancy
c16d08579d Stop running metabase-enterprise.transforms-inspector-test in cloud DBs (#77231)
Stop running metabase-enterprise.transforms-inspector-test in cloud DBs.

These tests create new tables in the default schema, which is fine for
most drivers, but not for cloud DBs like snowflake and bigquery, because
concurrent CI jobs share the same jobs, which means this will cause test
failures in other unrelated tests which list tables, such as
metabase-enterprise.impersonation.driver-test/resilient-connection-options-test

We need to get these tests to stop creating tables in the default schema,
then we can drop the requirement for the :test/dynamic-dataset-loading feature.
2026-07-08 17:47:57 +00:00
Phoomparin Mano
07c824b6fb fix(sdk): derive dark mode color scheme based on sdk theme colors (#77269)
* add css variables for styled components

* commit loki snapshot change

* add resolved theme mocks

* use real theme color values
2026-07-08 16:24:19 +00:00
Nicola Mometto
6aa06eaa23 Fix notification seed legacy template paths (#77268)
* Fix notification seed crashing on pre-existing template rows

* add regression test
2026-07-08 16:58:49 +01:00
Andrei Chernykh
7876a74202 Uxw 4531 replace the group picker dropdown with a multiselect (#76648)
* Replace the group picker in the user form with a multiselect

* Port the tenant group-picker e2e steps to the new multiselect

* Add group-manager controls and a section separator to the multiselect, with chevron and Backspace fixes

* Fix external-default group ordering in the multiselect dropdown, with manager-flag and divider tests

* Update frontend/src/metabase/admin/people/components/GroupsMultiSelect/GroupsMultiSelect.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update remove-button test assertions for the group-specific aria-label

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-08 08:35:46 -07:00
dpsutton
c09cdbf49c Serialize static-viz GraalJS context creation (GHY-4077) (#77225)
* Serialize static-viz GraalJS context creation (GHY-4077)

Even with the shared-Source code cache (#77180), evaluating the bundle
in a fresh context churns through ~500 MB of allocation. That memory is
transient — allocated and discarded during the eval, with only ~10 MB
retained per extra live context — but it all still has to pass through
the heap. The dirigiste pool grows during render bursts, and nothing
stopped 2-3 context creations from running concurrently: ~1-1.5 GB of
allocation churn stacked on top of in-flight renders, which on a 2 GB
heap outruns the collector and drives GC thrash / OOM.

Serialize creation on a private monitor so at most one generate runs at
a time; waiting acquires just queue on the pool. Also jitter the
10-minute expiry timestamps (+/- 3 min) so pool members created in the
same burst don't all expire and regenerate in the same window.

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

* Document static-viz pool memory model in namespace docstring

Capture what the OOM investigation measured so the pool's design
choices stay legible: engine + parsed sources ~220 MB retained (paid
once); fresh-context eval ~500 MB of collectable churn with only
~16 MB retained per live context (first-in-JVM cold parse ~1.2 GB);
renders ~1 MB/row transient. Also spell out why creation is serialized
on a monitor rather than time-throttled: bursts genuinely need
multiple contexts, so the pool still grows — just one creation at a
time.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 10:12:58 -05:00
Michael Eby
23825aec8e docs: add mcp + cli webinar callouts (#77191)
* docs: add mcp + cli webinar callouts

* Edit from Jeff
2026-07-08 10:29:39 -04:00
Chris Vandoodewaard
eca47dd2dc DEV-2270: add .bun-version file, configure setup-bun to use version file (#77208)
* add .bun-version file, configure setup-bun to use version file

* use package.json as the source of truth for the version of bun installed
2026-07-08 10:23:09 -04:00
Ngoc Khuat
8a7b431057 Fix redshift transform job dependency-detection flake (#77095) 2026-07-08 14:59:08 +01:00
Fraser
384f6fb9b7 Make test stats upload more robust (#77258)
* Make test stats upload more robust

* Fix trailing whitespace

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 13:47:04 +00:00
github-automation-metabase
b7fa7d73d2 Auto-update documentation for master (#77222)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-07-08 09:28:53 -04:00
github-automation-metabase
9d523b4604 Auto-update documentation for master (#77036)
Update generated docs (master)

Co-authored-by: github-actions <github-actions@github.com>
2026-07-08 09:27:54 -04:00
Sébastien
951be524ec Fix embed preview stuck loader when reopening with a saved theme (#77150) 2026-07-08 15:06:28 +02:00
Ngoc Khuat
c518575acc run the incremental transform suite against all drivers (coverage probe) (#76793) 2026-07-08 11:56:27 +00:00
Ngoc Khuat
206ba7ee99 Don't fail dashboard subscriptions when a filter's default is an empty list (#77158) 2026-07-08 11:43:49 +00:00
Sébastien
e78a7c57e6 MCP Usage Analytics: storage, instrumentation, audit view, and admin page (MVP) (#76403) 2026-07-08 12:15:21 +02:00
Sergii Vozniuk
892974e74e Fix rendering of tenant avatars (#77075)
* Fix rendering of tenant avatars
* Remove unused Avatar code, cleanup initials logic, more explicit types
* Fix type used in isUser
* Stricter types to avoid initial bug in the future
2026-07-08 11:58:50 +02:00
Nemanja Glumac
1bec6455d3 Wire embedding tests to ci-conductor (#77184)
* Wire embedding tests to ci-conductor reporting

* Add ci-conductor quarantine gate to embedding Cypress jobs


Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:19:26 +02:00
Chris Truter
0149067bd7 execute_question: improve error codes (#77156) 2026-07-08 07:46:17 +00:00
Romeo Van Snick
54a2825522 Convert route-prop reference containers onto the metabase/router facade (#77067)
* Add withRouteProps facade shim for the router migration

* Convert DatabaseListContainer to a function component on facade hooks

* Put route-prop reference containers on withRouteProps

* Require route props in withRouteProps generic constraint

* Convert ScrollToTop from withRouter to useLocation hook form

* Fix merge fallout: rename RouteProps shim and repoint react-router imports
2026-07-08 16:23:09 +10:00
Romeo Van Snick
ca6033b698 Convert query_builder test loose ends to TypeScript (#76938)
* Convert query_builder test loose ends to TypeScript

* Rename ChartSettingsSidebar tests to match their assertions
2026-07-08 14:22:49 +10:00
Fraser
f6be96587f Delete dead frontend code (#77253) 2026-07-08 16:02:20 +12:00
Fraser
1bc18a3626 Consolidate reference-only components into reference/ (#77254) 2026-07-08 16:01:57 +12:00
Fraser
837a454620 Consolidate app banners into AppBanner (#77255) 2026-07-08 16:01:43 +12:00
Fraser
3127b4e8c3 Consolidate question-timeline UI into query_builder (#77256) 2026-07-08 16:01:26 +12:00
Romeo Van Snick
56879decb0 Migrate direct react-router usage onto the metabase/router facade + eslint ban (#77068)
* Facade re-exports full react-router surface via a single leaf seam

* Re-point all react-router imports onto the metabase/router facade

* Ban react-router imports outside the metabase/router facade (eslint)

* Migrate InviteToViewModal Link import onto metabase/router facade
2026-07-08 12:53:05 +10:00
Alexander Polyankin
d81c75451c Minify the static-viz bundle (#77234) 2026-07-08 02:18:57 +00:00
Alexander Polyankin
b48d367c21 Log only the error message for failed queries (#77223) 2026-07-07 21:38:18 -04:00
Denis Berezin
5c6e3e71fe UXW-4235 Transforms nav item permissions (#76705) 2026-07-08 01:29:14 +03:00
Alexander Polyankin
5e05b04e53 Pool error fix (#77212) 2026-07-07 17:00:26 -04:00
Timothy S. Dean
5a39e1adf8 Update version-helpers.ts for v63 (#77211)
Update version-helpers.ts for v63
2026-07-07 14:39:58 -06:00
Eric Normand
ff5e3b6973 Validate serialization dependencies before export (GHY-4010) (#76763)
* Add serialization-dependencies multimethod + Card impl (GHY-4010)

Foundation for export-time dependency validation. serialization-dependencies
runs on a raw appdb entity and returns its references tagged :content (must be
in the export) or :data (database/table/field that must exist in the appdb).
Unlike the load-time `dependencies` multimethod, it operates pre-serialization
and includes tables/fields, which import otherwise creates on the fly.

The Card impl derives content deps from `descendants` (so the two can't drift)
and adds data-model deps from the query via the lib walkers. Not yet wired into
the export pipeline.

* Replace escape analysis with general dependency validation (GHY-4010)

extract-subtrees now runs a dry-run pass (collect-dependencies) that shares the
real export's extract queries but neither serializes nor writes. For every entity
that will be extracted it derives serialization-dependencies and checks them:
content references must be in the visited/extracted set (or be analytics cards),
and data-model references (database/table/field) must exist in the appdb. Any
unsatisfied reference aborts the export (unless continue-on-error), before any
archive is produced.

This generalizes part A's Card-only, collection-id escape analysis: it now also
catches non-Card content escapees and dangling data-model references (a deleted
table/field that would otherwise serialize to a malformed [nil nil nil] ref).

Adds a Dashboard serialization-dependencies impl. Tests not yet updated.

* Update serialization tests for dependency validation (GHY-4010)

The dependency-validation warnings replace the old "Failed to export
Dashboard/Cards ... outside requested collections" messages with a uniform
"<entity> references <model> <id> which is not included in the export /
is missing from the source database" form, so the escape tests now assert
on the new wording. Adds dependency-validation-missing-data-ref-test, which
covers the new data-model case: a card referencing a deleted field aborts the
export (and continue-on-error still exports the rest).

* Fold dependency collection over a monoid, with batched queries (GHY-4010)

Replaces the nested reduce in collect-dependencies with a {:visited :deps}
monoid: entity-deps maps one entity to its contribution, merge-deps is the
associative combine (0/1/2-arity, usable directly as a transduce reducing fn),
and collect-dependencies folds every entity into the whole via a single
transduce. content-id-batches yields [model id-batch] pairs so each extract
query's :in :id clause stays bounded (query-batch-size 1000), and the data-dep
existence check is batched the same way. Behavior is unchanged.

* Flatten dependency collection into four transducer stages (GHY-4010)

entity-deps now reads its model off the entity (extract-query returns modeled
rows) instead of taking it as an argument, so collect-dependencies becomes a
flat pipeline: filter content models, resize into id batches, mapcat each batch
through extract-query into an entity stream, then map entity-deps — no eduction
nested inside the mapcat. Behavior unchanged.

* Move dependency validation into its own namespace (GHY-4010)

Extract the export-time dependency-satisfaction check out of
serialization.v2.extract into serialization.v2.dependency-validation. extract
now just calls dependency-validation/validate-dependencies! and keeps ownership
of the analytics-card set, which it passes in. The moved code carries this
session's cleanups (map-kv-vals for the existence lookup, set-based :deps).

Tests move to a matching dependency-validation-test namespace; the escape
assertion embedded in selective-serialization-basic-test stays with extract as
an extraction-level integration check.

* Address review: drop :kind, transients, cover segment/measure (GHY-4010)

Alex's PR comments on the raw dependency validation:

- serialization-dependencies no longer tags deps with :kind; it returns plain
  {:model :id} and the validator classifies each by model (serdes.models
  content vs data-model), since serdes already knows which is which.
- collect-dependencies accumulates into transient sets — an export can walk a
  lot of entities.
- Card serialization-dependencies now covers every reference in the query,
  including :segment and :measure (both data-model, existence-checked), via the
  lib walkers, closing the gap the field/table/card-only version had.

Refs embedded in parameter_mappings / visualization_settings / result_metadata
still aren't covered, and the raw serialization walker still duplicates the
serialized *-deps knowledge; both are tracked for the follow-up unification.

* Cover result_metadata and visualization_settings field refs (GHY-4010)

serialization-dependencies "Card" now gathers Field references from every
routine-backed source, not just the query:

- query: lib walkers (fields, tables, segments, measures, template-tag fields)
- result_metadata: each column's :id
- visualization_settings: the JSON-encoded column-setting keys, parsed with
  mb.viz/db->norm (the same routine the dependency-analysis project uses)

New test covers a field referenced only in visualization_settings. Field refs
buried in parameter_mappings / parameters value-fields are bare [:field ...]
clauses the lib walkers reject; those remain for the dep-walker unification.

* Rename `dependencies` multimethod to `deserialization-dependencies`

Makes the load-time dep multimethod parallel with the export-time
`serialization-dependencies`. `dependencies` runs on the serialized/ingested
form for load ordering and omits tables/fields (import synthesizes them);
`serialization-dependencies` runs on the raw entity and includes them. The
name now says which is which.

Mechanical rename of the defmulti + default + 19 impls across 18 models + 3
callers (load, metabot, remote-sync) + tests + doc-links. No behavior change.

* Address CodeRabbit: batch analytics lookup, fix test targeting (GHY-4010)

- analytics-card-ids batches the card-id :in via a partition-all transducer, so
  a large closure doesn't exceed database parameter limits (matches the batching
  already used in dependency collection).
- escape-report-test's ":parameters" case targeted coll2 (re-testing the
  source-table card); target coll3 so it actually exercises the parameter card.
- Correct the continue-on-error test wording: it suppresses the abort but does
  not skip the affected card at export (the malformed card is skipped on import).

* Unify serialization/deserialization dependency walkers (GHY-4010)

Make the mbql-deps walker family format-aware so a single traversal handles
both raw (numeric ids, export/serialization) and serialized (portable refs,
load/deserialization) forms, gated by *serialization?* / raw-ref-id?. Card and
Dashboard now share one deps helper across both dependency multimethods.

Implement serialization-dependencies for the remaining content models
(Action, Collection, Document, NativeQuerySnippet, Timeline), computing deps
from the raw entity: Action fetches its query_action child row; Collection
derives its parent from the raw :location path.

Refine export-time validation: a content reference to a deleted entity is not a
failure (export can't emit a portable id for a missing row, so fk-elide drops
it), and structural Collection references are ignored (a selective export may
omit the containing collection tree).

Tests: mbql-deps format-parity test, direct per-model serialization-dependencies
coverage, and the full baseline load->extract round-trip over every model.

* Trim duplicated dependency docstrings and consolidate abort tests (GHY-4010)

Dedupe the "content refs must be in export / data-model refs must exist"
explanation that appeared in four docstrings, and collapse the three
near-identical "delete a data-model ref -> export aborts" tests into one
parameterized test over Field/Segment/viz-settings references.

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

* Share query-batch-size constant; use pos-int? for raw ref ids (GHY-4010)

Hoist the `:in`-clause batch size into a single serdes/query-batch-size
constant instead of a private def plus a bare 1000 literal in extract, and
tighten raw-ref-id? to pos-int? (entity ids are always positive).

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

* Replace transient accumulator with a named merge-entity-deps monoid (GHY-4010)

The set-building is not the hot path (batched DB queries and the mbql walk
dominate), so drop the transients for a plain `merge-with into` monoid,
extracted as `merge-entity-deps` — the same idiom as u/traverse and
remote-sync's merge-incremental-export-plans.

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

* Fold partition-all into the existing-ids transducer (GHY-4010)

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

* Fold distinct into the existing-ids transducer too (GHY-4010)

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

* Use peek and set-as-predicate in dependency validation (GHY-4010)

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

* SPIKE: thread allow-int-ids? param instead of *serialization?* dyn var (GHY-4010)

Replaces the *serialization?* dynamic var with an explicit allow-int-ids?
parameter threaded through the mbql walkers (mbql-deps/-map/-vector,
parameters-deps, visualization-settings-deps, viz-link-card-deps) and the
shared model helpers (card-deps, dashboard-deps, serdes-deps-dashcard,
result-metadata-deps). The name states the behavior: integer ids count as
references only when set (raw appdb entities), never on the serialized/load
form where a stray numeric must be ignored.

The parameter is mandatory everywhere — no default arities. Load-side callers
(measure/segment/transform/action deserialization impls) pass false; the three
serialization-dependencies impls pass true.

Spike to compare against the dyn-var approach; drop if we prefer that.

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

* Fix cljfmt: align card-deps destructuring continuation lines (GHY-4010)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:22:39 +00:00
Alexander Polyankin
486296ff36 Graalvm pool with staticly parsed sources (#77180) 2026-07-07 15:27:15 -04:00
Nick Fitzpatrick
7f6472209d Move only show attachments toggle for email subscriptions (#77022)
* Move  only show attachments toggle for email subscriptions

* fix e2e test
2026-07-08 00:52:14 +05:30
3520 changed files with 484876 additions and 378330 deletions

View File

@@ -102,3 +102,7 @@ breakage keeps happening, *that* is the signal a comment was warranted.
- End all files with a newline
- When editing tabular code, where the columns line up, try to keep them aligned
- Spaces on a line with nothing after it is not allowed
- After changing module boundaries (adding/removing/renaming a `src` namespace, a cross-module `require`
or `:model/X` reference, or a new module), run `./bin/mage fix-modules-config` to regenerate
`.clj-kondo/config/modules/config.edn` and keep `metabase.core.modules-test` green. No-op when nothing
drifted; see "Module Boundaries" in the project `CLAUDE.md`.

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!
@@ -187,10 +188,17 @@
metabase.channel.render.pdf.font/load-fonts!
metabase.warehouses.models.database/assert-not-h2!
metabase.driver.sql-jdbc.execute/execute-prepared-statement!
metabase.mcp.usage/record-mcp-session!
metabase.mcp.usage/record-mcp-session-end!
metabase.mcp.usage/record-mcp-tool-call!
metabase.notification.models/create-notification!
metabase.oauth-server.core/reset-provider!
metabase.pulse.send/send-pulse!
metabase-enterprise.transforms-python.python-runner-test/execute!
;; MCP usage tests isolate their rows by unique keys (session id / tool name / duration /
;; client version) and clean up only those, so these deletes are safe in parallel.
metabase-enterprise.mcp.usage-test/cleanup!
metabase-enterprise.mcp.usage-test/cleanup-calls!
;; Workspace isolation functions are safe in parallel tests because they operate on
;; isolated, test-specific workspaces and databases that don't affect other tests
metabase-enterprise.workspaces.isolation/destroy-workspace-isolation!
@@ -446,6 +454,9 @@
metabase-enterprise.action-v2.test-util/with-test-tables! clojure.core/let
metabase-enterprise.content-translation.routes-test/with-new-secret-key! clojure.core/fn
metabase-enterprise.entity-retrieval.reconcile-test/with-isolated-index clojure.core/fn
metabase-enterprise.mfa.enrollment-flow-test/with-fresh-user-session! clojure.core/fn
metabase-enterprise.mfa.login-flow-test/with-enrolled-rasta! clojure.core/fn
metabase-enterprise.mfa.recovery-codes-test/with-confirmed-enrollment! clojure.core/fn
metabase-enterprise.serialization.metadata-file-import.wire-format-test/with-tiny-fixture! clojure.core/fn
metabase-enterprise.serialization.test-util/with-dbs clojure.core/fn
metabase-enterprise.serialization.test-util/with-random-dump-dir clojure.core/let
@@ -578,7 +589,7 @@
metabase.api.common.internal-test/with-jetty-server hooks.common/with-one-binding
metabase.api.macros/defendpoint hooks.metabase.api.macros/defendpoint
metabase.app-db.schema-migrations-test.impl/test-migrations hooks.metabase.app-db.schema-migrations-test.impl/test-migrations
metabase.channel.render.js.svg/with-static-viz-context hooks.common/with-one-top-level-binding
metabase.channel.render.js.pool/with-static-viz-context hooks.common/with-one-top-level-binding
metabase.channel.template.handlebars-helper/defhelper hooks.metabase.channel.template.handlebars-helper/defhelper
metabase.channel.template.handlebars-test/with-temp-template! hooks.common/with-vec-first-binding
metabase.classloader.core/require hooks.clojure.core.require/lint-require
@@ -614,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*
@@ -752,6 +766,7 @@
metabase.test/with-temporary-raw-setting-values macros.metabase.test.util/with-temporary-raw-setting-values
metabase.users.models.user-test/with-groups! macros.metabase.users.models.user-test/with-groups!
metabase.util.namespaces/import-fn macros.metabase.util.namespaces/import-fn
metabase.util.namespaces/import-macro macros.metabase.util.namespaces/import-macro
metabase.xrays.related-test/with-world macros.metabase.xrays.related-test/with-world}}
:config-in-comment

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
@@ -180,6 +181,7 @@
:model/Collection
:model/Dashboard
:model/DashboardCard
:model/DashboardTab
:model/Database
:model/Table
:model/User}}
@@ -193,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
@@ -829,7 +840,6 @@
metabase.driver.common
metabase.driver.connection
metabase.driver.ddl.interface
metabase.driver.h2
metabase.driver.impl
metabase.driver.init
metabase.driver.mysql
@@ -1065,6 +1075,27 @@
:model-exports #{:model/ModelIndex :model/ModelIndexValue}
:model-imports #{:model/Card :model/Collection}}
indexes
{:team "Gadget"
:api #{metabase.indexes.models.table-index
metabase.indexes.reconcile
metabase.indexes.schema}
:uses #{driver
models
util}
:model-exports #{:model/TableIndex}}
indexes-rest
{:team "Gadget"
:api #{metabase.indexes-rest.api}
:uses #{api
indexes
transforms-base
util}
:model-imports #{:model/Database
:model/TableIndex
:model/Transform}}
initialization-status
{:team "UX West"
:api #{metabase.initialization-status.core}
@@ -1104,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
@@ -1227,13 +1259,19 @@
:api #{metabase.mcp.api
metabase.mcp.callback-api
metabase.mcp.core
metabase.mcp.init}
metabase.mcp.init
metabase.mcp.usage}
:model-exports #{:model/McpSessionLog
:model/McpToolCallLog}
:uses #{agent-api
ai-tracing
api
app-db
config
llm
metabot
oauth-server
premium-features
request
server
session
@@ -1272,6 +1310,7 @@
slackbot}
:uses #{activity-feed
agent-lib
ai-tracing
analytics
analytics-interface
api
@@ -1303,7 +1342,6 @@
settings
slackbot
sql-tools
store-api
sync
system
task
@@ -1319,6 +1357,8 @@
:model-imports #{:model/Card
:model/Collection
:model/Dashboard
:model/DashboardCard
:model/DashboardTab
:model/Database
:model/Field
:model/Glossary
@@ -1408,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
@@ -1851,6 +1902,7 @@
cache
config
dashboards
database-routing
driver
events
formatter
@@ -1944,20 +1996,12 @@
{:team "UX West"
:api #{metabase.sample-data.core
metabase.sample-data.init}
:uses #{config
models
permissions
:uses #{driver
plugins
settings
sync
util}
:model-imports #{:model/Card
:model/Dashboard
:model/DashboardCard
:model/DataPermissions
:model/Database
:model/Field
:model/Table}}
:model-imports #{:model/DataPermissions :model/Database :model/Table}}
search
{:team "UX West"
@@ -2071,6 +2115,7 @@
channel
config
events
premium-features
request
settings
sso
@@ -2168,8 +2213,7 @@
sql-parsing
{:team "DevEx"
:api #{metabase.sql-parsing.core}
:uses #{analytics-interface
util}}
:uses #{analytics-interface util}}
sql-tools
{:team "DevEx"
@@ -2308,9 +2352,11 @@
config
lib
lib-be
mcp
permissions
premium-features
search
session
util}
:model-imports #{:model/AiUsageLog
:model/Card
@@ -2375,6 +2421,7 @@
database-routing
driver
events
indexes
lib
lib-be
models
@@ -2404,6 +2451,7 @@
:model/Database
:model/Field
:model/Table
:model/TableIndex
:model/User}}
transforms-base
@@ -2417,11 +2465,13 @@
:model-imports #{:model/Database
:model/Field
:model/Table
:model/TableIndex
:model/Transform
:model/TransformRun}
:uses #{database-routing
driver
events
indexes
lib
lib-be
premium-features
@@ -2848,6 +2898,7 @@
:uses #{driver
premium-features
enterprise/advanced-config
enterprise/mfa
enterprise/scim
enterprise/semantic-search
enterprise/sso}
@@ -3081,7 +3132,6 @@
{:team "UX West"
:api #{metabase-enterprise.database-routing.api}
:uses #{api
config
database-routing
driver
events
@@ -3254,6 +3304,18 @@
premium-features}
:model-imports #{:model/Card :model/Collection :model/Table}}
enterprise/mcp
{:team "Metabot"
:api #{metabase-enterprise.mcp.init}
:uses #{analytics
mcp
metabot
premium-features
task
util}
:model-imports #{:model/McpSessionLog
:model/McpToolCallLog}}
enterprise/memoize-monitor
{:team "Graphy"
:api #{metabase-enterprise.memoize-monitor.init}
@@ -3307,6 +3369,22 @@
util}
:model-imports #{:model/MetabotConversation :model/MetabotFeedback :model/MetabotMessage}}
enterprise/mfa
{:team "Graphy"
:api #{metabase-enterprise.mfa.core
metabase-enterprise.mfa.init
metabase-enterprise.mfa.routes}
:uses #{api
appearance
channel
config
events
premium-features
settings
sso
util}
:model-imports #{:model/AuthIdentity :model/User}}
enterprise/permission-debug
{:team "UX West"
:api #{metabase-enterprise.permission-debug.api}
@@ -3486,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

@@ -6,3 +6,8 @@
`(def ~(-> sym name symbol) "docstring" ~sym))
([sym name]
`(def ~name "docstring" ~sym)))
(defmacro import-macro
"Kondo macro replacement for [[metabase.util.namespaces/import-macro]]."
[sym]
`(def ~(-> sym name symbol) "docstring" ~sym))

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

@@ -1,5 +1,6 @@
.git
.babel_cache
target/*
target
.circleci
.cpcache
.devcontainer
@@ -15,6 +16,7 @@ test_config/*
test_modules/*
test_resources/*
node_modules
**/node_modules
**metabase.jar
*.db

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

@@ -16,5 +16,5 @@ runs:
- name: Install bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: "1.3.14"
bun-version-file: 'package.json'
token: ${{ github.token }}

View File

@@ -6,7 +6,7 @@ runs:
steps:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: "1.3.14"
bun-version-file: 'package.json'
token: ${{ github.token }}
- name: Build release scripts

View File

@@ -1,8 +1,8 @@
name: CI Conductor quarantine gate (dry run)
name: CI Conductor quarantine gate
description: >-
Check this job's test failures against ci-conductor's quarantine list and print
a verdict. DRY RUN — it never fails the job. No-ops unless CI_CONDUCTOR_BASE_URL
is set.
Check this job's test failures against ci-conductor's quarantine list.
Either enforces the quarantine or only prints a verdict (dry run).
Fails closed when enforcing but the `CI_CONDUCTOR_BASE_URL` is missing or unreachable.
inputs:
adapter:
@@ -13,31 +13,23 @@ inputs:
Suite label to gate (CI_CONDUCTOR_TEST_SUITE). MUST match the label this
job reports under, or the gate checks the wrong quarantine list.
required: true
enforce-quarantine:
description: >-
Whether the gate actually gates the job. When "false", it only prints a verdict.
required: false
default: "false"
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: "1.3.14"
token: ${{ github.token }}
- name: Prepare CI Conductor
uses: ./.github/actions/prepare-ci-conductor
- name: Check failures against the quarantine list
shell: bash
env:
CI_CONDUCTOR_TEST_SUITE: ${{ inputs.test-suite }}
ADAPTER: ${{ inputs.adapter }}
QUARANTINE_DRY_RUN: "true"
QUARANTINE_DRY_RUN: ${{ inputs.enforce-quarantine != 'true' }}
run: |
case "$ADAPTER" in
backend|frontend|e2e) ;;

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: "1.3.14"
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

@@ -14,8 +14,15 @@ inputs:
description: Suffix to append to the test logs artifact name to avoid conflicts in matrix jobs
edition:
required: false
default: 'ee'
default: "ee"
description: Edition to test (oss or ee)
quarantine-engine:
required: false
driver-status:
required: false
default: ""
description: >-
Configured status for this driver ("info", "skip", "required", or empty), as reported by `mage driver-decisions`.
outputs:
test-outcome:
@@ -39,35 +46,25 @@ runs:
run: bun run build-static-viz
shell: bash
env:
MB_EDITION: ee
MB_EDITION: ${{ inputs.edition }}
- name: Test database driver
run: clojure -X:dev:ci:${{ inputs.edition }}:${{ inputs.edition }}-dev:drivers:drivers-dev:test ${{ inputs.test-args }}
shell: bash
id: run-test
# Trunk Quarantine controls the final state of the run in the CI!
# Even if this step fails we let it pass, and then `trunk-io/analytics-uploader` determines
# whether to fail or to pass the whole job, based on the quarantine list.
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
# Driver "info" status can affect this logic. See below.
continue-on-error: true
- name: Report failed tests to ci-conductor
# Positioned right after the test run (before the report/upload steps) so it
# reads pristine JUnit. Gated to non-clean runs + continue-on-error so it can
# never change the job outcome — Trunk Quarantine remains the sole arbiter.
# junit-name is the per-job identity label (e.g. be-tests-postgres-ee); only
# workflows that also set CI_CONDUCTOR_BASE_URL actually POST. See DEV-2224 /
# DEV-2235.
if: ${{ always() && steps.run-test.outcome != 'success' }}
if: ${{ !cancelled() && steps.run-test.outcome == 'failure' }}
# This step should NEVER fail the job!
continue-on-error: true
uses: ./.github/actions/report-failures-to-ci-conductor
with:
test-suite: ${{ inputs.junit-name }}
adapter: backend
- name: Quarantine gate (dry run)
if: ${{ always() && steps.run-test.outcome != 'success' }}
continue-on-error: true
uses: ./.github/actions/quarantine-gate
with:
adapter: backend
test-suite: ${{ inputs.junit-name }}
- name: Publish Test Report (JUnit)
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
# As per the documentation: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#steps-context
@@ -81,6 +78,7 @@ runs:
list-suites: failed
list-tests: failed
fail-on-error: false
- name: Upload Logs on Failure
if: always() && steps.run-test.outcome != 'success'
uses: actions/upload-artifact@v7
@@ -90,3 +88,20 @@ runs:
retention-days: 1
# Failing to upload the artifact should never affect the outcome of the whole test run.
continue-on-error: true
# Last, because this is the only step allowed to fail the job: everything above it reports the
# failure (JUnit report, logs, ci-conductor) and must run regardless of the verdict below.
- name: Quarantine gate
# Off master and release branches an "info" driver is data-collection only - the test always passes!
# On master and release branches "info" carries no weight - the quarantine logic applies.
if: |
!cancelled()
&& steps.run-test.outcome == 'failure'
&& !(inputs.driver-status == 'info'
&& !(github.ref_name == 'master' || startsWith(github.ref_name, 'release-x')))
continue-on-error: ${{ inputs.quarantine-engine != 'ci-conductor' }}
uses: ./.github/actions/quarantine-gate
with:
adapter: backend
test-suite: ${{ inputs.junit-name }}
enforce-quarantine: ${{ inputs.quarantine-engine == 'ci-conductor' }}

View File

@@ -34,6 +34,10 @@ inputs:
description: >-
Optional label disambiguating matrix legs of the same job, so the info-driver
job summary and PR comment stay distinct per leg.
run-trunk:
required: false
default: "true"
description: Whether to run the Trunk step at all.
runs:
using: "composite"
@@ -60,13 +64,11 @@ runs:
aws s3 cp ${OUTPUT_FILE}.zip s3://$BUCKET/$DATE/$GITHUB_RUN_ID/$GITHUB_RUN_ATTEMPT/
- name: Upload results to Trunk
if: ${{ always() && github.repository_owner == 'metabase' && inputs.run-trunk == 'true' }}
# Important!
# 1. This step must always run because it controls job's exit code.
# 2. There's no point in running this outside the organization
# 3. For "info" drivers off master/release branches, our own status takes precedence: we let this
# step pass so the job is reported as passing regardless of the real outcome (still uploaded).
# On master and release branches, info status is ignored and the real outcome gates the job.
if: always() && github.repository_owner == 'metabase'
# For "info" drivers off master/release branches, our own status takes precedence: we let this
# step pass so the job is reported as passing regardless of the real outcome (still uploaded).
# On master and release branches, info status is ignored and the real outcome gates the job.
continue-on-error: ${{ inputs.driver-status == 'info' && !(github.ref_name == 'master' || startsWith(github.ref_name, 'release-x')) }}
uses: trunk-io/analytics-uploader@1198efcc0976501147b10a7e343c3fa069527526 # v2.0.9
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

@@ -30,7 +30,7 @@ class UploadError extends Error {
}
}
async function attemptUpload(url, jsonData) {
async function attemptUpload(url, jsonData, timeoutMs) {
let response;
try {
response = await fetch(url, {
@@ -39,7 +39,11 @@ async function attemptUpload(url, jsonData) {
"x-api-key": process.env.API_KEY
},
// Rebuilt per attempt: a consumed request body can't be reused.
body: jsonToCsvFormData(jsonData)
body: jsonToCsvFormData(jsonData),
// Without this the request can hang indefinitely on a stalled server,
// eating the whole job timeout. An abort surfaces below as a retryable
// network error, so it retries with backoff instead.
signal: AbortSignal.timeout(timeoutMs)
});
} catch (networkError) {
throw new UploadError(networkError.message, { retryable: true });
@@ -61,16 +65,17 @@ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
/**
* Uploads jsonData as CSV, retrying transient API errors with exponential
* backoff. `retries` is the number of extra attempts after the first (so the
* default 2 means up to 3 tries). Throws the last error once attempts are
* exhausted or the error is non-retryable.
* default 2 means up to 3 tries). `timeoutMs` is a per-attempt total deadline
* (default 60s) that aborts a stalled request. Throws the last error once
* attempts are exhausted or the error is non-retryable.
*/
async function uploadCsvToMb({ baseUrl, tableId, jsonData, mode = 'append', retries = 2, retryDelayMs = 1000 }) {
async function uploadCsvToMb({ baseUrl, tableId, jsonData, mode = 'append', retries = 2, retryDelayMs = 1000, timeoutMs = 60_000 }) {
const operation = mode === 'replace' ? 'replace-csv' : 'append-csv';
const url = `${baseUrl}/api/table/${tableId}/${operation}`;
for (let attempt = 0; ; attempt++) {
try {
await attemptUpload(url, jsonData);
await attemptUpload(url, jsonData, timeoutMs);
return { success: true };
} catch (error) {
if (!error.retryable || attempt >= retries) {

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..."

View File

@@ -7,48 +7,58 @@ const { uploadCsvToMb } = require("./csv-to-mb.js");
// "FE Affected Tests" uploaded table on stats.metabase.com.
const TABLE_ID = 81818;
const s = JSON.parse(process.env.STATS_JSON);
console.log("stats", s);
async function main() {
// create-test-plan doesn't always produce stats (e.g. on release branches
// running an older planner), leaving STATS_JSON empty. Nothing to upload.
const raw = (process.env.STATS_JSON || "").trim();
if (!raw || raw === "null") {
console.log("No STATS_JSON to upload; skipping.");
return;
}
const row = {
Date: new Date().toISOString(),
// "pr_update" or "merge_to_master". ("Trigger" is a reserved SQL word.)
"Triggered By": process.env.TRIGGERED_BY,
PR: Number(process.env.PR_NUMBER),
"Head SHA": process.env.HEAD_SHA,
"Base SHA": process.env.BASE_SHA,
"FE Files Changed": s.fe_files_changed,
"FE Files Total": s.fe_files_total,
"BE Files Changed": s.be_files_changed,
"BE Files Total": s.be_files_total,
"Unit Infra Touched": s.unit_infra_touched,
"Loki Infra Touched": s.loki_infra_touched,
"Shared Sources Touched": s.shared_sources_touched,
"FE Modules Total": s.fe_modules_total,
"FE Modules Changed": s.fe_modules_changed,
"FE Modules Affected (Rules)": s.fe_modules_affected_rules,
"FE Modules Affected (Usage)": s.fe_modules_affected_usage,
"Unit Specs All": s.unit_specs_all,
"Unit Specs To Run (Rules)": s.unit_specs_to_run_rules,
"Unit Specs To Run (Usage)": s.unit_specs_to_run_usage,
"Loki Stories All": s.loki_stories_all,
"Loki Stories To Run (Rules)": s.loki_stories_to_run_rules,
"Loki Stories To Run (Usage)": s.loki_stories_to_run_usage,
"E2E Specs All": s.e2e_specs_all,
"E2E Specs To Run (Rules)": s.e2e_specs_to_run_rules,
"E2E Specs To Run (Usage)": s.e2e_specs_to_run_usage,
};
const s = JSON.parse(raw);
console.log("stats", s);
uploadCsvToMb({
baseUrl: "https://stats.metabase.com",
tableId: TABLE_ID,
jsonData: [row],
mode: "append",
})
.then(() => {
console.log("Data uploaded successfully");
})
.catch((error) => {
console.error("Error uploading data:", error);
process.exit(1);
const row = {
Date: new Date().toISOString(),
// "pr_update" or "merge_to_master". ("Trigger" is a reserved SQL word.)
"Triggered By": process.env.TRIGGERED_BY,
PR: Number(process.env.PR_NUMBER),
"Head SHA": process.env.HEAD_SHA,
"Base SHA": process.env.BASE_SHA,
"FE Files Changed": s.fe_files_changed,
"FE Files Total": s.fe_files_total,
"BE Files Changed": s.be_files_changed,
"BE Files Total": s.be_files_total,
"Unit Infra Touched": s.unit_infra_touched,
"Loki Infra Touched": s.loki_infra_touched,
"Shared Sources Touched": s.shared_sources_touched,
"FE Modules Total": s.fe_modules_total,
"FE Modules Changed": s.fe_modules_changed,
"FE Modules Affected (Rules)": s.fe_modules_affected_rules,
"FE Modules Affected (Usage)": s.fe_modules_affected_usage,
"Unit Specs All": s.unit_specs_all,
"Unit Specs To Run (Rules)": s.unit_specs_to_run_rules,
"Unit Specs To Run (Usage)": s.unit_specs_to_run_usage,
"Loki Stories All": s.loki_stories_all,
"Loki Stories To Run (Rules)": s.loki_stories_to_run_rules,
"Loki Stories To Run (Usage)": s.loki_stories_to_run_usage,
"E2E Specs All": s.e2e_specs_all,
"E2E Specs To Run (Rules)": s.e2e_specs_to_run_rules,
"E2E Specs To Run (Usage)": s.e2e_specs_to_run_usage,
};
await uploadCsvToMb({
baseUrl: "https://stats.metabase.com",
tableId: TABLE_ID,
jsonData: [row],
mode: "append",
});
console.log("Data uploaded successfully");
}
// Best-effort: never let a telemetry failure turn the job red.
main().catch((error) => {
console.error("Skipping stats upload after error:", error);
process.exit(0);
});

3
.github/team.json vendored
View File

@@ -93,7 +93,8 @@
"deniskaber",
"andsalves",
"kevinkelleher12",
"AndreyChernykh"
"AndreyChernykh",
"bowbahdoe"
]
},
{

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

@@ -76,7 +76,7 @@ jobs:
github.event_name == 'push' &&
(
github.ref_name == 'master' ||
startsWith(github.ref_name, 'release-')
startsWith(github.ref_name, 'release-x')
) &&
''
) ||
@@ -120,6 +120,7 @@ jobs:
test-args: >-
${{ matrix.job.test-args }}
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -131,6 +132,7 @@ jobs:
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' }}
be-tests-mysql:
if: ${{ !inputs.skip }}
@@ -191,7 +193,7 @@ jobs:
github.event_name == 'push' &&
(
github.ref_name == 'master' ||
startsWith(github.ref_name, 'release-')
startsWith(github.ref_name, 'release-x')
) &&
''
) ||
@@ -243,6 +245,7 @@ jobs:
test-args: >-
${{ matrix.job.test-args }}
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -254,6 +257,7 @@ jobs:
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' }}
be-tests-postgres:
if: ${{ !inputs.skip }}
@@ -307,7 +311,7 @@ jobs:
github.event_name == 'push' &&
(
github.ref_name == 'master' ||
startsWith(github.ref_name, 'release-')
startsWith(github.ref_name, 'release-x')
) &&
''
) ||
@@ -353,6 +357,7 @@ jobs:
test-args: >-
${{ matrix.job.test-args }}
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -364,6 +369,7 @@ jobs:
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' }}
app-db-tests-result:
needs:

View File

@@ -45,7 +45,7 @@ jobs:
be-linter-eastwood-test:
# Master / release safety net: lint every backend test namespace.
if: ${{ !inputs.skip && github.event_name == 'push' && (github.ref_name == 'master' || startsWith(github.ref_name, 'release-')) }}
if: ${{ !inputs.skip && github.event_name == 'push' && (github.ref_name == 'master' || startsWith(github.ref_name, 'release-x')) }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 45
name: Eastwood (tests only)
@@ -121,7 +121,7 @@ jobs:
strategy:
fail-fast: false
matrix:
java-version: [21]
java-version: [25]
job:
- name: Enterprise Tests
edition: ee
@@ -167,7 +167,7 @@ jobs:
github.event_name == 'push' &&
(
github.ref_name == 'master' ||
startsWith(github.ref_name, 'release-')
startsWith(github.ref_name, 'release-x')
) &&
''
) ||
@@ -197,35 +197,31 @@ jobs:
clojure -X:dev:ci:test:${{ matrix.job.edition }}:${{ matrix.job.edition }}-dev
:exclude-tags '[:mb/driver-tests ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
${{ matrix.job.test-args }}
# Trunk Quarantine controls the final state of the run in the CI!
# Even if this step fails we let it pass, and then `trunk-io/analytics-uploader` determines
# whether to fail or to pass the whole job, based on the quarantine list.
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
continue-on-error: true
- name: Report failed tests to ci-conductor
# Positioned between the test run and "Upload Test Results" so it reads
# pristine JUnit (before Trunk quarantine processing). Gated to non-clean
# runs + continue-on-error so it can never change the job outcome — Trunk
# Quarantine remains the sole arbiter. See DEV-2224 / DEV-2235.
if: ${{ always() && steps.run-java-tests.outcome != 'success' }}
if: ${{ !cancelled() && steps.run-java-tests.outcome == 'failure' }}
# This step should NEVER fail the job!
continue-on-error: true
uses: ./.github/actions/report-failures-to-ci-conductor
with:
test-suite: be-tests-java-${{ matrix.java-version }}-${{ matrix.job.test-group-name }}
adapter: backend
- name: Quarantine gate (dry run)
if: ${{ always() && steps.run-java-tests.outcome != 'success' }}
continue-on-error: true
- name: Quarantine gate
if: ${{ !cancelled() && steps.run-java-tests.outcome == 'failure' }}
continue-on-error: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
timeout-minutes: 3
uses: ./.github/actions/quarantine-gate
with:
adapter: backend
test-suite: be-tests-java-${{ matrix.java-version }}-${{ matrix.job.test-group-name }}
enforce-quarantine: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always() && (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 }}
@@ -234,6 +230,7 @@ jobs:
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.run-java-tests.outcome }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
- name: Publish Test Report (JUnit)
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
@@ -262,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,6 +46,7 @@ jobs:
sqlserver-should-run: ${{ steps.driver-decisions.outputs.sqlserver-should-run }}
vertica-should-run: ${{ steps.driver-decisions.outputs.vertica-should-run }}
# Per-driver configured status from ci-test-config (skip | info | required)
h2-status: ${{ steps.driver-decisions.outputs.h2-status }}
athena-status: ${{ steps.driver-decisions.outputs.athena-status }}
bigquery-status: ${{ steps.driver-decisions.outputs.bigquery-status }}
clickhouse-status: ${{ steps.driver-decisions.outputs.clickhouse-status }}
@@ -56,6 +57,7 @@ jobs:
mongo-sharded-cluster-status: ${{ steps.driver-decisions.outputs.mongo-sharded-cluster-status }}
mysql-mariadb-status: ${{ steps.driver-decisions.outputs.mysql-mariadb-status }}
oracle-status: ${{ steps.driver-decisions.outputs.oracle-status }}
postgres-status: ${{ steps.driver-decisions.outputs.postgres-status }}
presto-jdbc-status: ${{ steps.driver-decisions.outputs.presto-jdbc-status }}
redshift-status: ${{ steps.driver-decisions.outputs.redshift-status }}
snowflake-status: ${{ steps.driver-decisions.outputs.snowflake-status }}
@@ -136,7 +138,7 @@ jobs:
./bin/mage -driver-decisions "${MAGE_ARGS[@]}" --github-output-only >> $GITHUB_OUTPUT
env:
BASE_REF: ${{ github.event.pull_request.base.ref || github.ref_name }}
IS_MASTER_OR_RELEASE: ${{ github.ref_name == 'master' || startsWith(github.ref_name, 'release-') }}
IS_MASTER_OR_RELEASE: ${{ github.ref_name == 'master' || startsWith(github.ref_name, 'release-x') }}
shell: bash
be-tests-athena-ee:
@@ -177,6 +179,8 @@ jobs:
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.athena-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -190,6 +194,7 @@ jobs:
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.athena-status }}
report-variant: ${{ matrix.job.name }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
be-tests-bigquery-cloud-sdk-ee:
needs: determine-driver-skips
@@ -253,6 +258,8 @@ jobs:
junit-name: 'be-tests-bigquery-cloud-sdk-ee'
test-args: ${{ matrix.job.test-args }}
logs-artifact-suffix: ${{ matrix.job.name }}
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.bigquery-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -266,6 +273,7 @@ jobs:
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.bigquery-status }}
report-variant: ${{ matrix.job.name }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
- name: Cleanup python-runner
if: ${{ always() && matrix.job.needs-python-runner == true }}
uses: ./.github/actions/cleanup-python-runner
@@ -338,6 +346,8 @@ jobs:
junit-name: 'be-tests-clickhouse-ee'
test-args: ${{ matrix.job.test-args }}
logs-artifact-suffix: ${{ matrix.job.name }}
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.clickhouse-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -349,6 +359,8 @@ jobs:
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.clickhouse-status }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
- name: Cleanup python-runner
if: ${{ always() && matrix.job.needs-python-runner == true }}
uses: ./.github/actions/cleanup-python-runner
@@ -392,6 +404,8 @@ jobs:
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.druid-jdbc-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -403,12 +417,39 @@ jobs:
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.druid-jdbc-status }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
be-tests-databricks-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.databricks-should-run == 'true' || needs.determine-driver-skips.outputs.databricks-quarantine-conflict == 'true' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
job:
- name: Driver Tests Part 1
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
:fail-fast? true
:partition/total 3
:partition/index 0
- name: Driver Tests Part 2
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
:fail-fast? true
:partition/total 3
:partition/index 1
- name: Driver Tests Part 3
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
:fail-fast? true
:partition/total 3
:partition/index 2
env:
CI: 'true'
DRIVERS: databricks
@@ -437,9 +478,9 @@ jobs:
uses: ./.github/actions/test-driver
with:
junit-name: 'be-tests-databricks-ee'
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
test-args: ${{ matrix.job.test-args }}
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.databricks-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -453,12 +494,38 @@ jobs:
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.databricks-status }}
report-variant: ${{ matrix.job.name }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
be-tests-databricks-multi-catalog-ee:
needs: determine-driver-skips
if: ${{ needs.determine-driver-skips.outputs.databricks-should-run == 'true' || needs.determine-driver-skips.outputs.databricks-quarantine-conflict == 'true' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
job:
- name: Driver Tests Part 1
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
:fail-fast? true
:partition/total 3
:partition/index 0
- name: Driver Tests Part 2
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
:fail-fast? true
:partition/total 3
:partition/index 1
- name: Driver Tests Part 3
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
:fail-fast? true
:partition/total 3
:partition/index 2
env:
CI: 'true'
DRIVERS: databricks
@@ -488,9 +555,9 @@ jobs:
uses: ./.github/actions/test-driver
with:
junit-name: 'be-tests-databricks-multi-catalog-ee'
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
test-args: ${{ matrix.job.test-args }}
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.databricks-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -504,6 +571,7 @@ jobs:
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.databricks-status }}
report-variant: ${{ matrix.job.name }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
be-tests-h2:
needs: determine-driver-skips
@@ -523,6 +591,8 @@ jobs:
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.h2-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -534,6 +604,8 @@ jobs:
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.h2-status }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
be-tests-h2-oss:
needs: determine-driver-skips
@@ -554,6 +626,8 @@ jobs:
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.h2-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -565,6 +639,8 @@ jobs:
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.h2-status }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
be-tests-mysql-mariadb:
needs: determine-driver-skips
@@ -627,7 +703,7 @@ jobs:
github.event_name == 'push' &&
(
github.ref_name == 'master' ||
startsWith(github.ref_name, 'release-')
startsWith(github.ref_name, 'release-x')
) &&
''
) ||
@@ -684,6 +760,8 @@ jobs:
test-args: >-
${{ matrix.job.test-args }}
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.mysql-mariadb-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -695,6 +773,8 @@ jobs:
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.mysql-mariadb-status }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
- name: Cleanup python-runner
if: always()
uses: ./.github/actions/cleanup-python-runner
@@ -764,6 +844,8 @@ jobs:
junit-name: ${{ matrix.version.junit-name }}
test-args: ${{ matrix.job.test-args }}
logs-artifact-suffix: ${{ matrix.job.name }}
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.mongo-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -775,6 +857,8 @@ jobs:
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.mongo-status }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
- name: Cleanup python-runner
if: ${{ always() && matrix.job.needs-python-runner == true }}
uses: ./.github/actions/cleanup-python-runner
@@ -846,6 +930,8 @@ jobs:
test-args: >-
:exclude-tags '[:mongo-sharded-cluster-tests :mb/transforms-python-test]'
:only-tags [:mb/driver-tests]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.mongo-ssl-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -857,6 +943,8 @@ jobs:
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.mongo-ssl-status }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
be-tests-mongo-sharded-cluster-ee:
needs: determine-driver-skips
@@ -891,6 +979,8 @@ jobs:
junit-name: 'be-tests-mongo-sharded-cluster-ee'
test-args: >-
:only metabase.driver.mongo.sharded-cluster-test
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.mongo-sharded-cluster-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -902,6 +992,8 @@ jobs:
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.mongo-sharded-cluster-status }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
be-tests-oracle:
needs: determine-driver-skips
@@ -976,6 +1068,8 @@ jobs:
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.oracle-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -987,6 +1081,8 @@ jobs:
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.oracle-status }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
be-tests-postgres:
needs: determine-driver-skips
@@ -1029,7 +1125,7 @@ jobs:
github.event_name == 'push' &&
(
github.ref_name == 'master' ||
startsWith(github.ref_name, 'release-')
startsWith(github.ref_name, 'release-x')
) &&
''
) ||
@@ -1080,6 +1176,8 @@ jobs:
test-args: >-
${{ matrix.job.test-args }}
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.postgres-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -1091,6 +1189,9 @@ jobs:
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.postgres-status }}
report-variant: ${{ matrix.version.name }}-${{ matrix.job.name }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
- name: Cleanup python-runner
if: always()
uses: ./.github/actions/cleanup-python-runner
@@ -1167,6 +1268,8 @@ jobs:
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.presto-jdbc-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -1178,6 +1281,8 @@ jobs:
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.presto-jdbc-status }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
be-tests-redshift-ee:
needs: determine-driver-skips
@@ -1251,6 +1356,8 @@ jobs:
test-args: >-
${{ matrix.job.test-args }}
logs-artifact-suffix: ${{ matrix.job.name }}
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.redshift-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: ${{ !matrix.job.skip && !cancelled() }}
@@ -1264,6 +1371,7 @@ jobs:
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.redshift-status }}
report-variant: ${{ matrix.job.name }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
- name: Cleanup python-runner
if: ${{ always() && matrix.job.needs-python-runner == true }}
uses: ./.github/actions/cleanup-python-runner
@@ -1283,8 +1391,11 @@ jobs:
- name: Driver Tests
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
:exclude-tags '[:mb/upload-tests :mb/transforms-python-test]'
:fail-fast? true
- name: Upload Tests
test-args: >-
:only-tags [:mb/upload-tests]
- name: Transforms Python Tests
test-args: >-
:only-tags [:mb/transforms-python-test]
@@ -1333,6 +1444,8 @@ jobs:
junit-name: 'be-tests-snowflake-ee'
test-args: ${{ matrix.job.test-args }}
logs-artifact-suffix: ${{ matrix.job.name }}
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.snowflake-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -1346,6 +1459,7 @@ jobs:
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.snowflake-status }}
report-variant: ${{ matrix.job.name }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
- name: Cleanup python-runner
if: ${{ always() && matrix.job.needs-python-runner == true }}
uses: ./.github/actions/cleanup-python-runner
@@ -1387,6 +1501,8 @@ jobs:
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.sparksql-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -1398,6 +1514,8 @@ jobs:
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.sparksql-status }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
be-tests-sqlite-ee:
needs: determine-driver-skips
@@ -1428,6 +1546,8 @@ jobs:
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.sqlite-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -1439,6 +1559,8 @@ jobs:
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.sqlite-status }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
be-tests-sqlserver:
needs: determine-driver-skips
@@ -1504,6 +1626,8 @@ jobs:
junit-name: ${{ matrix.version.junit-name }}
test-args: ${{ matrix.job.test-args }}
logs-artifact-suffix: ${{ matrix.version.name }}
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.sqlserver-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -1515,6 +1639,8 @@ jobs:
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.sqlserver-status }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
- name: Cleanup python-runner
if: ${{ always() && matrix.job.needs-python-runner == true }}
uses: ./.github/actions/cleanup-python-runner
@@ -1629,6 +1755,8 @@ jobs:
test-args: >-
:only-tags [:mb/driver-tests]
:exclude-tags [:mb/transforms-python-test]
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
driver-status: ${{ needs.determine-driver-skips.outputs.vertica-status }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always()
@@ -1640,6 +1768,8 @@ jobs:
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
driver-status: ${{ needs.determine-driver-skips.outputs.vertica-status }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
drivers-tests-result:
needs:
@@ -1693,11 +1823,13 @@ jobs:
"be-tests-clickhouse-ee": "${{ needs.determine-driver-skips.outputs.clickhouse-status }}",
"be-tests-databricks-ee": "${{ needs.determine-driver-skips.outputs.databricks-status }}",
"be-tests-druid-jdbc-ee": "${{ needs.determine-driver-skips.outputs.druid-jdbc-status }}",
"be-tests-h2": "${{ needs.determine-driver-skips.outputs.h2-status }}",
"be-tests-mongo": "${{ needs.determine-driver-skips.outputs.mongo-status }}",
"be-tests-mongo-ssl": "${{ needs.determine-driver-skips.outputs.mongo-ssl-status }}",
"be-tests-mongo-sharded-cluster-ee": "${{ needs.determine-driver-skips.outputs.mongo-sharded-cluster-status }}",
"be-tests-mysql-mariadb": "${{ needs.determine-driver-skips.outputs.mysql-mariadb-status }}",
"be-tests-oracle": "${{ needs.determine-driver-skips.outputs.oracle-status }}",
"be-tests-postgres": "${{ needs.determine-driver-skips.outputs.postgres-status }}",
"be-tests-presto-jdbc-ee": "${{ needs.determine-driver-skips.outputs.presto-jdbc-status }}",
"be-tests-redshift-ee": "${{ needs.determine-driver-skips.outputs.redshift-status }}",
"be-tests-snowflake-ee": "${{ needs.determine-driver-skips.outputs.snowflake-status }}",

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

@@ -164,9 +164,7 @@ jobs:
node e2e/runner/run_cypress_ci.js e2e \
--expose grepTags="-@mongo+-@python+-@OSS+-@skip" \
--spec "$SPEC_PATH"
# Trunk Quarantine controls the final state of the run in the CI!
# Even if this step fails we let it pass, and then `trunk-io/analytics-uploader` determines
# whether to fail or to pass the whole job, based on the quarantine list.
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
continue-on-error: true
- name: Run Tagged EE Cypress tests on ${{ inputs.name }}
@@ -181,9 +179,7 @@ jobs:
node e2e/runner/run_cypress_ci.js e2e \
--expose grepTags="${{inputs.tags && format('{0}+-@skip', inputs.tags) || '-@mongo+-@python+-@OSS+-@skip'}}" \
--spec "$SPEC_PATH"
# Trunk Quarantine controls the final state of the run in the CI!
# Even if this step fails we let it pass, and then `trunk-io/analytics-uploader` determines
# whether to fail or to pass the whole job, based on the quarantine list.
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
continue-on-error: true
- name: Rewrite JUnit hook failures
@@ -213,17 +209,18 @@ jobs:
variant: e2e-tests
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ inputs.specs && steps.tagged-e2e.outcome || steps.split-e2e.outcome }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
- name: Quarantine gate (dry run)
# always(), unlike backend/frontend: the run step is continue-on-error
# (Trunk governs its outcome), so its result can't gate on real failures.
if: always()
continue-on-error: true
- name: Quarantine gate
# Only one of the two run steps executes; the other reports `skipped`. Only gate on actual failure.
if: ${{ !cancelled() && (steps.split-e2e.outcome == 'failure' || steps.tagged-e2e.outcome == 'failure') }}
continue-on-error: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
timeout-minutes: 3
uses: ./.github/actions/quarantine-gate
with:
adapter: e2e
test-suite: e2e
enforce-quarantine: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
- name: Upload Cypress Artifacts upon failure
uses: actions/upload-artifact@v7

View File

@@ -8,6 +8,12 @@ env:
TERM: xterm
TZ: US/Pacific # to make Node match the instance tz
MB_EDITION: "ee"
CI_CONDUCTOR_BASE_URL: ${{ secrets.CI_CONDUCTOR_BASE_URL }}
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
REPO_ID: ${{ github.event.repository.id }}
COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
TARGET_BRANCH: ${{ github.event.pull_request.base.ref || github.ref_name }}
QUARANTINE_FAILURES_FILE: ${{ github.workspace }}/target/quarantine-failures.jsonl
jobs:
sdk-component-tests:
@@ -19,6 +25,7 @@ jobs:
react-version: [18, 19]
fail-fast: false
env:
CI_CONDUCTOR_TEST_SUITE: embedding-sdk-component-react-${{ matrix.react-version }}
# Sensitive env vars (tokens, secrets) starting with `CYPRESS_` are accessed via async `cy.env()`
# Example: `cy.env(["FOO"]).then(({ FOO }) => ...)`
# For non-sensitive config, use `Cypress.expose()` in cypress config instead (synchronous)
@@ -69,6 +76,8 @@ jobs:
- name: Run Metabase
run: node e2e/runner/run_cypress_ci.js start
- uses: ./.github/actions/resolve-job-id
- name: Make app db snapshot
env:
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
@@ -86,6 +95,8 @@ jobs:
env:
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
run: node e2e/runner/run_cypress_ci.js component
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
continue-on-error: true
- name: Rewrite JUnit hook failures
if: always()
@@ -111,10 +122,21 @@ jobs:
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.run-tests.outcome }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
- name: Quarantine gate
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
continue-on-error: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
timeout-minutes: 3
uses: ./.github/actions/quarantine-gate
with:
adapter: e2e
test-suite: ${{ env.CI_CONDUCTOR_TEST_SUITE }}
enforce-quarantine: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
- name: "SDK component tests > Upload Cypress Artifacts upon failure"
uses: actions/upload-artifact@v7
if: failure()
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
with:
name: cypress-recording-embedding-sdk-react-${{ matrix.react-version }}
path: |
@@ -123,7 +145,7 @@ jobs:
if-no-files-found: ignore
- name: "SDK component tests > Publish Summary"
if: failure()
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
uses: actions/github-script@v9
with:
script: | #js
@@ -168,6 +190,7 @@ jobs:
env:
MB_RUN_MODE: "e2e"
METASTORE_DEV_SERVER_URL: ${{ secrets.METASTORE_DEV_SERVER_URL }}
CI_CONDUCTOR_TEST_SUITE: embedding-sdk-component-backward-compatibility
# Sensitive env vars (tokens, secrets) starting with `CYPRESS_` are accessed via async `cy.env()`
# Example: `cy.env(["FOO"]).then(({ FOO }) => ...)`
# For non-sensitive config, use `Cypress.expose()` in cypress config instead (synchronous)
@@ -237,6 +260,8 @@ jobs:
- name: Run Metabase
run: node e2e/runner/run_cypress_ci.js start
- uses: ./.github/actions/resolve-job-id
- name: Make app db snapshot
env:
CYPRESS_BROWSER: ${{ steps.cypress-prep.outputs.chrome-path }}
@@ -252,6 +277,8 @@ jobs:
run: |
node e2e/runner/run_cypress_ci.js component \
--expose grepTags="-@skip-backward-compatibility"
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
continue-on-error: true
- name: Rewrite JUnit hook failures
if: always()
@@ -277,10 +304,21 @@ jobs:
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.run-tests.outcome }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
- name: Quarantine gate
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
continue-on-error: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
timeout-minutes: 3
uses: ./.github/actions/quarantine-gate
with:
adapter: e2e
test-suite: ${{ env.CI_CONDUCTOR_TEST_SUITE }}
enforce-quarantine: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
- name: Upload Cypress Artifacts upon failure
uses: actions/upload-artifact@v7
if: failure()
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
with:
name: cypress-recording-embedding-sdk-cross-version
path: |
@@ -289,7 +327,7 @@ jobs:
if-no-files-found: ignore
- name: Publish Summary
if: failure()
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
uses: actions/github-script@v9
with:
script: | #js

View File

@@ -171,6 +171,8 @@ jobs:
- name: Log free disk space
run: df -h /
- uses: ./.github/actions/resolve-job-id
- name: Run e2e tests for Sample App
id: run-e2e-tests
if: ${{ steps.check-sample-app-branch.outputs.sample_app_branch_exists == 'true' }}
@@ -238,6 +240,8 @@ jobs:
shoppy-datadog-application-id: ${{ secrets.SHOPPY_METABASE_APP_DATADOG_APPLICATION_ID }}
shoppy-datadog-client-token: ${{ secrets.SHOPPY_METABASE_APP_DATADOG_CLIENT_TOKEN }}
- uses: ./.github/actions/resolve-job-id
- name: Run e2e tests for Shoppy
id: run-e2e-tests
if: ${{ steps.shoppy-prep.outputs.sample_app_branch_exists == 'true' }}
@@ -316,6 +320,8 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: ./.github/actions/resolve-job-id
- name: Run synthetic monitoring tests
id: run-e2e-tests
if: ${{ steps.shoppy-prep.outputs.sample_app_branch_exists == 'true' }}
@@ -363,6 +369,19 @@ jobs:
timeout-minutes: 45
name: e2e-host-app-${{ matrix.test_suite }}-tests-${{ matrix.environment }}-bundle
env:
# Host apps are the only suite here running through Metabase's Cypress config,
# whose `after:spec` hook reports failures and writes QUARANTINE_FAILURES_FILE.
# Sample-app suites load their own config (no-op), so ci-conductor is scoped to this job.
CI_CONDUCTOR_BASE_URL: ${{ secrets.CI_CONDUCTOR_BASE_URL }}
CI_CONDUCTOR_WEBHOOK_SECRET: ${{ secrets.CI_CONDUCTOR_WEBHOOK_SECRET }}
CI_CONDUCTOR_TEST_SUITE: embedding-sdk-host-app
REPO_ID: ${{ github.event.repository.id }}
COMMIT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
TARGET_BRANCH: ${{ github.event.pull_request.base.ref || github.ref_name }}
QUARANTINE_FAILURES_FILE: ${{ github.workspace }}/target/quarantine-failures.jsonl
# `Fail job if tests failed` is the arbiter until ci-conductor takes over.
# Exactly one of the two gates the job.
CI_CONDUCTOR_QUARANTINE_ACTIVE: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
CYPRESS_MB_ALL_FEATURES_TOKEN: ${{ secrets.STAGING_MB_ALL_FEATURES_TOKEN }}
HOST_APP_ENVIRONMENT: ${{ matrix.environment }}
permissions:
@@ -399,6 +418,8 @@ jobs:
- name: Run Metabase
run: node e2e/runner/run_cypress_ci.js start
- uses: ./.github/actions/resolve-job-id
- name: Generate database snapshots
run: |
node e2e/runner/run_cypress_ci.js snapshot \
@@ -416,14 +437,24 @@ jobs:
- name: Upload Cypress Artifacts upon failure
uses: actions/upload-artifact@v7
if: ${{ steps.run-e2e-tests.outcome != 'success' }}
if: ${{ !cancelled() && steps.run-e2e-tests.outcome == 'failure' }}
with:
name: cypress-recording-host-app-${{ matrix.test_suite }}-${{ matrix.environment }}-latest
path: |
./cypress
if-no-files-found: ignore
- name: Quarantine gate
if: ${{ !cancelled() && steps.run-e2e-tests.outcome == 'failure' }}
continue-on-error: ${{ env.CI_CONDUCTOR_QUARANTINE_ACTIVE != 'true' }}
timeout-minutes: 3
uses: ./.github/actions/quarantine-gate
with:
adapter: e2e
test-suite: ${{ env.CI_CONDUCTOR_TEST_SUITE }}
enforce-quarantine: ${{ env.CI_CONDUCTOR_QUARANTINE_ACTIVE }}
- name: Fail job if tests failed
if: ${{ steps.run-e2e-tests.outcome != 'success' }}
if: ${{ !cancelled() && steps.run-e2e-tests.outcome == 'failure' && env.CI_CONDUCTOR_QUARANTINE_ACTIVE != 'true' }}
run: |
exit 1

View File

@@ -110,29 +110,25 @@ jobs:
- name: Run frontend unit tests
id: fe-tests
run: bun run test-unit --silent --shard=${{ matrix.shard }}/${{ env.SHARDS }}
# Trunk Quarantine controls the final state of the run in the CI!
# Even if this step fails we let it pass, and then `trunk-io/analytics-uploader` determines
# whether to fail or to pass the whole job, based on the quarantine list.
# Keep this step continue-on-error because the quarantine logic controls whether it ultimately fails.
continue-on-error: true
- name: Report failed tests to ci-conductor
# Positioned between the test run and "Upload Test Results" so it reads
# pristine JUnit (before Trunk quarantine processing). Gated to non-clean
# runs + continue-on-error so it can never change the job outcome — Trunk
# Quarantine remains the sole arbiter. See DEV-2224 / DEV-2235.
if: ${{ always() && steps.fe-tests.outcome != 'success' }}
if: ${{ !cancelled() && steps.fe-tests.outcome == 'failure' }}
# This step should NEVER fail the job!
continue-on-error: true
uses: ./.github/actions/report-failures-to-ci-conductor
with:
test-suite: fe-tests-unit
adapter: frontend
- name: Quarantine gate (dry run)
if: ${{ always() && steps.fe-tests.outcome != 'success' }}
continue-on-error: true
- name: Quarantine gate
if: ${{ !cancelled() && steps.fe-tests.outcome == 'failure' }}
continue-on-error: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
timeout-minutes: 3
uses: ./.github/actions/quarantine-gate
with:
adapter: frontend
test-suite: fe-tests-unit
enforce-quarantine: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
- name: Upload Test Results
uses: ./.github/actions/upload-test-results
if: always() && github.event_name != 'workflow_dispatch'
@@ -144,6 +140,7 @@ jobs:
aws-region: ${{ vars.AWS_REGION }}
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
previous-step-outcome: ${{ steps.fe-tests.outcome }}
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
fe-tests-timezones:
if: ${{ !inputs.skip }}

View File

@@ -1,118 +0,0 @@
name: Sync Linear Release
on:
push:
branches:
- master
- "release-x.**"
workflow_dispatch:
inputs:
command:
description: "sync | update | complete"
type: choice
options: [sync, update, complete]
default: sync
release_version:
description: "Override minor version, e.g. v0.57.8 (else derived from branch)"
type: string
required: false
stage:
description: "Target stage (required for 'update'), e.g. rc"
type: string
required: false
permissions:
contents: read
jobs:
sync-linear-release:
continue-on-error: true
if: ${{ github.repository == 'metabase/metabase' }}
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
steps:
- name: Check out (full history + tags — linear-release needs a full clone)
uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
- name: Build release code from master
run: git checkout master
- name: Build the release scripts
uses: ./.github/actions/prepare-release-scripts
- name: Derive the in-flight MINOR version + scan baseline (via release/ helpers)
id: ctx
uses: actions/github-script@v9
continue-on-error: true
env:
REF_NAME: ${{ github.ref_name }}
# master develops the next GA major. cut-release-branch.yml bumps this
# org var to the just-cut major, so master == CURRENT_VERSION + 1.
# Confirm this matches your conventions before enabling.
CURRENT_VERSION: ${{ vars.CURRENT_VERSION }}
INPUT_VERSION: ${{ github.event.inputs.release_version }}
with:
script: | # js
try {
const R = require('${{ github.workspace }}/release/dist/index.cjs');
const { REF_NAME, CURRENT_VERSION, INPUT_VERSION } = process.env;
const owner = context.repo.owner;
const repo = context.repo.repo;
// Manual dispatch override wins.
if (INPUT_VERSION) {
const releaseVersion = R.getLinearReleaseName(INPUT_VERSION);
console.log({ REF_NAME, CURRENT_VERSION, INPUT_VERSION, releaseVersion });
core.setOutput('release_version', releaseVersion);
core.setOutput('base_ref', '');
return;
}
// Branch -> major: release branches encode it; master develops the next major.
const major = REF_NAME.startsWith('release-x.')
? R.getMajorVersionNumberFromReleaseBranch(REF_NAME)
: String(Number(CURRENT_VERSION) + 1);
// Last STABLE MINOR shipped on this line (ignore patches + pre-releases),
// so the release we record is always the next MINOR (e.g. v0.63.3) and a
// patch build (v0.63.2.3) maps to its parent minor rather than its own release.
const lastMinorTag = await R.getLastReleaseTag({
github, owner, repo,
version: `v0.${major}.0`, // only the major is read out of this
ignorePatches: true,
ignorePreReleases: true,
});
const releaseVersion = lastMinorTag
? R.findNextMinorVersion(lastMinorTag) // v0.63.2 -> v0.63.3
: `v0.${major}.0`; // .0 not cut yet (done manually)
const linearReleaseName = R.getLinearReleaseName(releaseVersion);
console.log({
REF_NAME, CURRENT_VERSION, INPUT_VERSION,
major, lastMinorTag, releaseVersion, linearReleaseName,
});
core.setOutput('release_version', linearReleaseName);
core.setOutput('base_ref', lastMinorTag ?? '');
core.info(`Linear release ${linearReleaseName} (baseline: ${lastMinorTag ?? 'none'})`);
} catch (err) { // this should never fail a CI check
console.error(err);
}
- name: Restore branch (for subsequent steps)
run: git checkout ${{ github.ref_name }} # restore branch
- name: Sync / update / complete the Linear release
if: ${{ steps.ctx.outputs.release_version }}
uses: linear/linear-release-action@v0
with:
access_key: ${{ secrets.LINEAR_RELEASE_ACCESS_KEY }}
command: ${{ github.event.inputs.command || 'sync' }}
release_version: ${{ steps.ctx.outputs.release_version }}
base_ref: ${{ steps.ctx.outputs.base_ref }}
stage: ${{ github.event.inputs.stage }}
# Linear extracts ENG-ids AND detects PR numbers from the scanned commits,
# so a PR that closes no Linear issue is still tracked on the release.

View File

@@ -13,6 +13,8 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: 'package.json'
- name: Install dependencies
run: bun install --frozen-lockfile
- name: get module boundary stats

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

@@ -33,7 +33,7 @@ jobs:
- name: Install bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: "1.3.14"
bun-version-file: 'package.json'
token: ${{ secrets.GITHUB_TOKEN }}
- name: Prepare build scripts
run: bun install --cwd release --frozen-lockfile && bun run --cwd release build

View File

@@ -39,7 +39,7 @@ jobs:
- name: Install bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: "1.3.14"
bun-version-file: 'package.json'
token: ${{ secrets.GITHUB_TOKEN }}
- name: prepare dependencies in release directory
run: bun install --cwd release --frozen-lockfile
@@ -58,7 +58,7 @@ jobs:
- name: Install bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: "1.3.14"
bun-version-file: 'package.json'
token: ${{ secrets.GITHUB_TOKEN }}
- name: prepare dependencies in release directory
run: bun install --cwd release --frozen-lockfile
@@ -77,7 +77,7 @@ jobs:
- name: Install bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: "1.3.14"
bun-version-file: 'package.json'
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install ci-conductor dependencies
run: bun install --cwd release/ci-conductor --frozen-lockfile
@@ -98,7 +98,7 @@ jobs:
- name: Install bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: "1.3.14"
bun-version-file: 'package.json'
token: ${{ secrets.GITHUB_TOKEN }}
- name: prepare dependencies in release directory
run: bun install --cwd release --frozen-lockfile

View File

@@ -554,53 +554,6 @@ jobs:
--distribution-id ${{ vars.AWS_CLOUDFRONT_STATIC_ID }} \
--paths "/version-info.json" "/version-info-ee.json"
linear-release-lifecycle:
# Advance / finalize the matching Linear release (single "Metabase" pipeline).
# Only minors and majors map to a Linear release; patches fold into their minor
# and have no release of their own, so we skip them (same gate as the jobs above).
# Pre-releases (RC/beta) -> `update` the stage (keep it open); stable -> `complete`.
# Requires the LINEAR_RELEASE_ACCESS_KEY secret + the "Metabase" pipeline (see
# release/LINEAR_RELEASES.md). The sync side lives in linear-release-sync.yml.
if: ${{ needs.check-version.outputs.kind == 'minor' || needs.check-version.outputs.kind == 'major' }}
needs: [push-tags, check-version]
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
timeout-minutes: 10
continue-on-error: true # don't block the release if Linear fails (we can fix it manually)
steps:
- uses: actions/checkout@v6
with:
# complete/update target a release by version and do NOT scan commits,
# so the default shallow clone is enough (sync, which scans, needs depth 0).
sparse-checkout: |
release
.github
- name: Prepare release scripts
uses: ./.github/actions/prepare-release-scripts
- name: Resolve the Linear MINOR release + lifecycle command
id: linear
uses: actions/github-script@v9
with:
script: | # js
const R = require('${{ github.workspace }}/release/dist/index.cjs');
const version = '${{ inputs.version }}';
const releaseVersion = R.getLinearReleaseName(version);
const preRelease = R.isPreReleaseVersion(version);
core.setOutput('release_version', releaseVersion);
// if we're doing a beta, we want to update the stage (keep it open) instead of completing it
core.setOutput('command', preRelease ? 'update' : 'complete');
core.setOutput('stage', preRelease ? 'beta' : 'Released');
- name: Advance / complete the Linear release
uses: linear/linear-release-action@v0
with:
access_key: ${{ secrets.LINEAR_RELEASE_ACCESS_KEY }}
command: ${{ steps.linear.outputs.command }}
release_version: ${{ steps.linear.outputs.release_version }}
stage: ${{ steps.linear.outputs.stage }}
publish-complete-message:
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
needs: [add-extra-tags]

View File

@@ -34,7 +34,7 @@ jobs:
- 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_v3/tools/semantic_search_test.clj"]'
: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:
@@ -61,6 +61,7 @@ jobs:
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()
@@ -72,6 +73,7 @@ jobs:
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-mariadb:
if: ${{ !inputs.skip }}
@@ -93,7 +95,7 @@ jobs:
- 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_v3/tools/semantic_search_test.clj"]'
: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:
@@ -134,6 +136,7 @@ jobs:
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()
@@ -145,6 +148,7 @@ jobs:
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-mysql:
if: ${{ !inputs.skip }}
@@ -166,7 +170,7 @@ jobs:
- 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_v3/tools/semantic_search_test.clj"]'
: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:
@@ -207,6 +211,7 @@ jobs:
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()
@@ -218,6 +223,7 @@ jobs:
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-postgres:
if: ${{ !inputs.skip }}
@@ -239,7 +245,7 @@ jobs:
- 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_v3/tools/semantic_search_test.clj"]'
: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:
@@ -281,6 +287,7 @@ jobs:
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()
@@ -292,6 +299,73 @@ jobs:
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' }}
# 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:
@@ -299,6 +373,7 @@ jobs:
- 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

@@ -53,7 +53,7 @@ jobs:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: "1.3.14"
bun-version-file: 'package.json'
token: ${{ secrets.GITHUB_TOKEN }}
# Snyk doesn't support bun.lock yet, so we generate yarn.lock and scan that.
# Bun has a bug where it normalizes "< 3.0.0" to "< 3" in alias entries

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

1
.gitignore vendored
View File

@@ -204,3 +204,4 @@ __pycache__/
# dependency-cruiser output
dependency-graph.json
.playwright-cli/

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.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 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

@@ -58,6 +58,25 @@ For module-scoped runs — useful when validating a branch's blast radius — pa
Once again, do not use `clj -X:dev:test` directly — its progress-bar output is hard to parse.
## Module Boundaries
The linter config at `.clj-kondo/config/modules/config.edn` records each module's `:api`, `:uses`,
`:model-exports`, and `:model-imports`. `metabase.core.modules-test` fails when it drifts from the source.
After **any** backend change that could shift module boundaries, regenerate it:
```bash
./bin/mage fix-modules-config
```
Changes that shift boundaries include: adding/removing/renaming a `src` namespace, adding or dropping a
cross-module `require` or `:model/X` reference, or creating a new module. When unsure, just run it — it is
a no-op (exits `unchanged`) when nothing drifted.
It piggybacks on a running dev nREPL (~5s) and auto-spawns a JVM if none is running (~15s). It only edits
the four generated keys; structural changes it can't safely make (a new module needs a human `:team`, or
modules need reordering) are printed as `WARNING:` lines for you to resolve by hand.
## Tool Preferences
If `clojure-mcp` tools are available, prefer them over shell-based alternatives for Clojure development.

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

9
bb.edn
View File

@@ -265,6 +265,15 @@
:requires [[mage.modules]]
:task (task! (mage.modules/cli-can-skip-driver-tests (:arguments parsed)))}
fix-modules-config
{:doc "Regenerate .clj-kondo/config/modules/config.edn so it passes metabase.core.modules-test."
:examples [["./bin/mage fix-modules-config" "Fix the modules config (uses the running dev REPL if available, else a JVM)"]
["./bin/mage fix-modules-config --port 59498" "Use the dev REPL on a specific nREPL port"]]
;; Cold-JVM fallback invokes `clojure -X:dev dev.modules-config/fix-config!`.
:options [["-p" "--port PORT" "nREPL port to use, defaults to value in .nrepl-port"]]
:requires [[mage.modules]]
:task (task! (mage.modules/cli-fix-config (cli/parse! (current-task))))}
ls
{:doc "List all mage public and private tasks"
:examples [["./bin/mage ls" "List all tasks"]]

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}"

168
bun.lock
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",
@@ -98,12 +97,11 @@
"dompurify": "^3.4.11",
"echarts": "6.1.0",
"events": "^3.3.0",
"eventsource-parser": "^3.0.6",
"fast-text-encoding": "^1.0.6",
"formik": "^2.4.5",
"frimousse": "^0.3.0",
"fuse.js": "^7.1.0",
"hast-util-from-html": "^2.0.1",
"hast-util-to-html": "^9.0.0",
"history": "3",
"html2canvas-pro": "^2.0.2",
"icepick": "2.4.0",
@@ -112,7 +110,7 @@
"inflection": "^1.7.1",
"inquirer-toggle": "^1.0.1",
"js-cookie": "3.0.7",
"js-yaml": "^4.2.0",
"js-yaml": "^4.3.0",
"json5": "^2.2.2",
"jspdf": "^4.2.1",
"kbar": "0.1.0-beta.45",
@@ -144,15 +142,14 @@
"react-innertext": "^1.1.5",
"react-is": "^18.2.0",
"react-markdown": "^9.0.0",
"react-qr-code": "^2.0.15",
"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",
@@ -167,7 +164,8 @@
"tinykeys": "^3.0.0",
"ts-pattern": "^5.9.0",
"ttag": "1.7.21",
"typescript": "^6.0.3",
"typescript": "6.0.3",
"typescript7": "npm:typescript@7.0.2",
"underscore": "^1.13.8",
"url": "^0.11.4",
"use-sync-external-store": "^1.6.0",
@@ -204,6 +202,7 @@
"@rspack/dev-server": "^2.0.1",
"@rspack/plugin-react-refresh": "^2.0.0",
"@sinonjs/fake-timers": "^9.1.2",
"@stardazed/streams-text-encoding": "^1.0.2",
"@storybook/addon-a11y": "^8.5.0",
"@storybook/addon-actions": "^8.5.0",
"@storybook/addon-essentials": "^8.5.0",
@@ -269,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",
@@ -360,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",
@@ -443,6 +442,8 @@
"follow-redirects": "^1.16.0",
"form-data": "4.0.6",
"js-cookie": "^3.0.7",
"js-yaml": "^4.3.0",
"linkify-it": "^5.0.2",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"markdown-it": "^14.2.0",
@@ -452,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",
@@ -1231,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=="],
@@ -1501,6 +1503,8 @@
"@snowplow/tracker-core": ["@snowplow/tracker-core@3.2.0", "", { "dependencies": { "tslib": "^2.3.0", "uuid": "^3.4.0" } }, "sha512-EsOs8HlWPp3SIFn3TSG5Eo8l/QEU0ZyHVIlvmoQT3fOdsWZVrTK9IlrWi/sA0S43tVxpHKLuiEKgmQIrQXuVyw=="],
"@stardazed/streams-text-encoding": ["@stardazed/streams-text-encoding@1.0.2", "", {}, "sha512-f2Z15BId3t44a/u21yYSGXFAkCyKocmAyduoAy7swnZ4xIfbaZlOWsgly/jDNNOuj6hYQN72UaBRe3Z/tOHfqg=="],
"@storybook/addon-a11y": ["@storybook/addon-a11y@8.5.0", "", { "dependencies": { "@storybook/addon-highlight": "8.5.0", "@storybook/test": "8.5.0", "axe-core": "^4.2.0", "vitest-axe": "^0.1.0" } }, "sha512-dTKlnhOaDsAXxkmHz7m6/qb98IENoaXTCG3fXo2iwJ1xT27fZF+i8fz8oQprLAN5r7xlnz66ARJvEIKJ+Lxjgw=="],
"@storybook/addon-actions": ["@storybook/addon-actions@8.5.0", "", { "dependencies": { "@storybook/global": "^5.0.0", "@types/uuid": "^9.0.1", "dequal": "^2.0.2", "polished": "^4.2.2", "uuid": "^9.0.0" } }, "sha512-6CW9+17rk5eNx6I8EKqCxRKtsJFTR/lHL+xiJ6/iBWApIm8sg63vhXvUTJ58UixmIkT5oLh0+ESNPh+x10D8fw=="],
@@ -1967,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=="],
@@ -1979,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=="],
@@ -2053,6 +2053,46 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"@uiw/codemirror-extensions-basic-setup": ["@uiw/codemirror-extensions-basic-setup@4.23.8", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-XJR/8AEVcE7ufy1BhW2nCN9qSVDYEdCtYLfvhaMwl6Q3qcaYYCGE2K5QbFCy7LsdP/3uZKvc1OskuqatoOPdhQ=="],
"@uiw/react-codemirror": ["@uiw/react-codemirror@4.23.8", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", "@uiw/codemirror-extensions-basic-setup": "4.23.8", "codemirror": "^6.0.0" } }, "sha512-/NA5Pj4MmXkLSlmlUm4yfEmRLntrNq5TkQKBSINn7TukXQ4fc+C6Bk0U60Qa4rkvCSgwzZdQ2exyP0t0+2GtqA=="],
@@ -2817,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=="],
@@ -3353,26 +3391,12 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hast-util-from-html": ["hast-util-from-html@2.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-RXQBLMl9kjKVNkJTIO6bZyb2n+cUH8LFaSSzo82jiLT6Tfc+Pt7VQCS+/h3YwG4jaNE2TA2sdJisGWR+aJrp0g=="],
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^8.0.0", "property-information": "^6.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-Er/Iixbc7IEa7r/XLtuG52zoqn/b3Xng/w6aZQ0xGVxzhw5xUFxcRqdPzP6yFi/4HBYRaifaI5fQ1RH8n0ZeOQ=="],
"hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="],
"hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hast-util-raw": ["hast-util-raw@9.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-PldBy71wO9Uq1kyaMch9AHIghtQvIwxBUkv823pKmkTM3oV1JxtsTNYdevMxvUHqcnOAuO65JKU2+0NOxc2ksA=="],
"hast-util-to-html": ["hast-util-to-html@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-raw": "^9.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-hZOofyZANbyWo+9RP75xIDV/gq+OUKx+T46IlwERnKmfpwp81XBFbT9mi26ws+SJchA4RVUQwIBJpqEOBhMzEQ=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-to-parse5": ["hast-util-to-parse5@8.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hastscript": ["hastscript@8.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-dMOtzCEd3ABUeSIISmrETiKuyydk1w0pa+gE/uormcTpSYuaNJPbX1NU3JLyscSLjwAQM8bWMhhIlnCqnRvDTw=="],
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="],
@@ -3405,8 +3429,6 @@
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"html-webpack-plugin": ["html-webpack-plugin@5.5.0", "", { "dependencies": { "@types/html-minifier-terser": "^6.0.0", "html-minifier-terser": "^6.0.2", "lodash": "^4.17.21", "pretty-error": "^4.0.0", "tapable": "^2.0.0" } }, "sha512-sy88PC2cRTVxvETRgUHFrL4No3UxvcH8G1NepGhqaTT+GXN2kTamqasot0inS5hXeg1cMbFDt27zzo9p35lZVw=="],
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
@@ -3743,7 +3765,7 @@
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="],
"js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
"jsbn": ["jsbn@0.1.1", "", {}, "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="],
@@ -3835,7 +3857,7 @@
"lines-and-columns": ["lines-and-columns@1.1.6", "", {}, "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA="],
"linkify-it": ["linkify-it@5.0.1", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg=="],
"linkify-it": ["linkify-it@5.0.2", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q=="],
"linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="],
@@ -4267,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=="],
@@ -4311,7 +4335,7 @@
"parse-passwd": ["parse-passwd@1.0.0", "", {}, "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY="],
"parse5": ["parse5@7.0.0", "", { "dependencies": { "entities": "^4.3.0" } }, "sha512-y/t8IXSPWTuRZqXc0ajH/UwDj4mnqLEbSttNbThcFhGrZuOyoyvNBO85PBp2jQa55wY9d07PBNjsK8ZP3K5U6g=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
@@ -4541,7 +4565,7 @@
"property-expr": ["property-expr@2.0.5", "", {}, "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA=="],
"property-information": ["property-information@6.2.0", "", {}, "sha512-kma4U7AFCTwpqq5twzC1YVIDXSqg6qQK6JN0smOw8fgRy1OkMi0CYSzFmsy6dnqSenamAtj0CyXMUJ1Mf6oROg=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"prosemirror-changeset": ["prosemirror-changeset@2.3.1", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ=="],
@@ -4601,6 +4625,8 @@
"qified": ["qified@0.5.0", "", { "dependencies": { "hookified": "^1.12.1" } }, "sha512-Zj6Q/Vc/SQ+Fzc87N90jJUzBzxD7MVQ2ZvGyMmYtnl2u1a07CejAhvtk4ZwASos+SiHKCAIylyGHJKIek75QBw=="],
"qrcode-generator": ["qrcode-generator@2.0.4", "", {}, "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g=="],
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
"query-string": ["query-string@4.3.4", "", { "dependencies": { "object-assign": "^4.1.0", "strict-uri-encode": "^1.0.0" } }, "sha1-u7aTucqRXCMlFbIosaArYJBD2+s="],
@@ -4673,6 +4699,8 @@
"react-number-format": ["react-number-format@5.4.4", "", {}, "sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA=="],
"react-qr-code": ["react-qr-code@2.2.0", "", { "dependencies": { "prop-types": "^15.8.1", "qrcode-generator": "^2.0.4" }, "peerDependencies": { "react": "*" } }, "sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw=="],
"react-reconciler": ["react-reconciler@0.26.2", "", { "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "scheduler": "^0.20.2" } }, "sha512-nK6kgY28HwrMNwDnMui3dvm3rCFjZrcGiuwLc5COUipBK5hWHLOxMJhSnSomirqWwjPBJKV1QcbkI0VJr7Gl1Q=="],
"react-redux": ["react-redux@9.1.2", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.3", "use-sync-external-store": "^1.0.0" } }, "sha512-0OA4dhM1W48l3uzmv6B7TXPCGmokUU4p1M44DGN2/D9a1FjVPukVjER1PcPX97jIg6aUeLq1XJo1IpfbgULn0w=="],
@@ -4687,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=="],
@@ -4737,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=="],
@@ -5161,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=="],
@@ -5337,6 +5359,8 @@
"typescript-plugin-css-modules": ["typescript-plugin-css-modules@5.1.0", "", { "dependencies": { "@types/postcss-modules-local-by-default": "^4.0.2", "@types/postcss-modules-scope": "^3.0.4", "dotenv": "^16.4.2", "icss-utils": "^5.1.0", "less": "^4.2.0", "lodash.camelcase": "^4.3.0", "postcss": "^8.4.35", "postcss-load-config": "^3.1.4", "postcss-modules-extract-imports": "^3.0.0", "postcss-modules-local-by-default": "^4.0.4", "postcss-modules-scope": "^3.1.1", "reserved-words": "^0.1.2", "sass": "^1.70.0", "source-map-js": "^1.0.2", "stylus": "^0.62.0", "tsconfig-paths": "^4.2.0" } }, "sha512-6h+sLBa4l+XYSTn/31vZHd/1c3SvAbLpobY6FxDiUOHJQG1eD9Gh3eCs12+Eqc+TCOAdxcO+zAPvUq0jBfdciw=="],
"typescript7": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
"uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
@@ -5435,8 +5459,6 @@
"vfile": ["vfile@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw=="],
"vfile-location": ["vfile-location@5.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-NXPYyxyBSH7zB5U6+3uDdd6Nybz6o6/od9rk8bp9H8GR3L+cm/fC0uUTbqBmUTnMCUDslAGBOIKNfvvb+gGlDg=="],
"vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="],
"vitest-axe": ["vitest-axe@0.1.0", "", { "dependencies": { "aria-query": "^5.0.0", "axe-core": "^4.4.2", "chalk": "^5.0.1", "dom-accessibility-api": "^0.5.14", "lodash-es": "^4.17.21", "redent": "^3.0.0" } }, "sha512-jvtXxeQPg8R/2ANTY8QicA5pvvdRP4F0FsVUAHANJ46YCDASie/cuhlSzu0DGcLmZvGBSBNsNuK3HqfaeknyvA=="],
@@ -5461,8 +5483,6 @@
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"web-streams-polyfill": ["web-streams-polyfill@4.1.0", "", {}, "sha512-A7Jxrg7+eV+eZR/CIdESDnRGFb6/bcKukGvJBB5snI6cw3is1c2qamkYstC1bY1p08TyMRlN9eTMkxmnKJBPBw=="],
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
@@ -5691,8 +5711,6 @@
"@hey-api/json-schema-ref-parser/@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@hey-api/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"@hey-api/openapi-ts/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"@hey-api/shared/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
@@ -5715,8 +5733,6 @@
"@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A=="],
"@jest/console/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"@jest/core/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
@@ -5813,14 +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/openapi-core/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"@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=="],
@@ -5951,16 +5965,12 @@
"@types/jest/pretty-format": ["pretty-format@30.2.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA=="],
"@types/jsdom/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"@types/leaflet/@types/geojson": ["@types/geojson@7946.0.8", "", {}, "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA=="],
"@types/react/@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@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=="],
@@ -6183,8 +6193,6 @@
"content-disposition/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"cosmiconfig/js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
"cosmiconfig/parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
"create-ecdh/bn.js": ["bn.js@4.11.9", "", {}, "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw=="],
@@ -6277,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=="],
@@ -6465,20 +6471,10 @@
"hasha/type-fest": ["type-fest@0.8.1", "", {}, "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA=="],
"hast-util-from-parse5/@types/unist": ["@types/unist@3.0.2", "", {}, "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ=="],
"hast-util-raw/@types/unist": ["@types/unist@3.0.2", "", {}, "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ=="],
"hast-util-raw/@ungap/structured-clone": ["@ungap/structured-clone@1.2.0", "", {}, "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="],
"hast-util-to-html/@types/unist": ["@types/unist@3.0.2", "", {}, "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ=="],
"hast-util-to-jsx-runtime/@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="],
"hast-util-to-jsx-runtime/@types/unist": ["@types/unist@3.0.2", "", {}, "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ=="],
"hast-util-to-jsx-runtime/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"hpack.js/readable-stream": ["readable-stream@2.3.7", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw=="],
@@ -6639,8 +6635,6 @@
"jsdom/html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
"jsdom/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"jsdom/whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
"jsdom/whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
@@ -6727,8 +6721,6 @@
"mocha/glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="],
"mocha/js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
"mocha/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
"mocha/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
@@ -6851,7 +6843,7 @@
"parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"parse5/entities": ["entities@4.3.1", "", {}, "sha512-o4q/dYJlmyjP2zfnaWDUC6A3BQFmVTX+tZPezK7k0GLSU9QYCauscf5Y+qcEPzKL+EixVouYDgLQK5H9GrLpkg=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"pascal-case/tslib": ["tslib@2.2.0", "", {}, "sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w=="],
@@ -6997,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=="],
@@ -7205,8 +7195,6 @@
"vfile/@types/unist": ["@types/unist@3.0.2", "", {}, "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ=="],
"vfile-location/@types/unist": ["@types/unist@3.0.2", "", {}, "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ=="],
"vfile-message/@types/unist": ["@types/unist@3.0.2", "", {}, "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ=="],
"vitest-axe/aria-query": ["aria-query@5.0.0", "", {}, "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg=="],
@@ -7295,8 +7283,6 @@
"write-file-atomic/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"yaml-lint/js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
"yargs/escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"yargs-unparser/camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
@@ -7371,8 +7357,6 @@
"@istanbuljs/load-nyc-config/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"@jest/core/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
"@jest/core/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
@@ -7505,12 +7489,8 @@
"@storybook/test/@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.13", "", {}, "sha512-R305kwb5CcMDIpSHUnLyIAp7SrSPBx6F0VfQFB3M75xVMHhXJJIdePYgbPPh1o57vCHNu5QztokWUPsLjWzFqw=="],
"@svgr/core/cosmiconfig/js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
"@svgr/core/cosmiconfig/parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
"@svgr/plugin-svgo/cosmiconfig/js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
"@svgr/plugin-svgo/cosmiconfig/parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
"@tiptap/extension-bubble-menu/@floating-ui/dom/@floating-ui/core": ["@floating-ui/core@1.6.8", "", { "dependencies": { "@floating-ui/utils": "^0.2.8" } }, "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA=="],
@@ -7523,8 +7503,6 @@
"@types/jest/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"@types/jsdom/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"@types/serve-index/@types/express/@types/express-serve-static-core": ["@types/express-serve-static-core@4.17.24", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*" } }, "sha512-3UJuW+Qxhzwjq3xhwXm2onQcFHn76frIYVbTu+kn24LFxI+dEhdfISDFovPB8VpEgW8oQCTpRuCe+0zJxB7NEA=="],
"@types/serve-index/@types/express/@types/serve-static": ["@types/serve-static@1.13.10", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ=="],
@@ -7993,8 +7971,6 @@
"jest-watcher/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
"jsdom/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"jsdom/whatwg-url/tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
"json-stable-stringify/call-bind/get-intrinsic": ["get-intrinsic@1.2.4", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2", "has-proto": "^1.0.1", "has-symbols": "^1.0.3", "hasown": "^2.0.0" } }, "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ=="],
@@ -8181,8 +8157,6 @@
"openapi-typescript/@redocly/openapi-core/@redocly/config": ["@redocly/config@0.22.2", "", {}, "sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ=="],
"openapi-typescript/@redocly/openapi-core/js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
"openapi-typescript/@redocly/openapi-core/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
"ora/cli-cursor/restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
@@ -8215,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=="],
@@ -8237,8 +8207,6 @@
"redoc/@redocly/openapi-core/@redocly/config": ["@redocly/config@0.22.2", "", {}, "sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ=="],
"redoc/@redocly/openapi-core/js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
"redoc/@redocly/openapi-core/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
"reg-cli/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],

View File

@@ -32,8 +32,8 @@
com.clojure-goes-fast/jvm-hiccup-meter {:mvn/version "0.1.1"} ; Monitor GC pauses and other system-induced pauses.
com.draines/postal {:mvn/version "2.0.5" ; SMTP library
:exclusions [com.sun.mail/jakarta.mail]}
com.fasterxml.jackson.core/jackson-core {:mvn/version "2.21.4"} ; pinned: cheshire pulls 2.21.1 which has resource exhaustion vuln
com.fasterxml.jackson.core/jackson-databind {:mvn/version "2.21.4"} ; pinned: snowplow pulls 2.13.3 which has DoS vulns
com.fasterxml.jackson.core/jackson-core {:mvn/version "2.21.5"} ; pinned: cheshire pulls 2.21.1 which has resource exhaustion vuln
com.fasterxml.jackson.core/jackson-databind {:mvn/version "2.21.5"} ; pinned: snowplow pulls 2.13.3 which has DoS vulns
com.fasterxml.woodstox/woodstox-core {:mvn/version "7.1.1"} ; trans dep of commons-codec
; Detect the charset in uploaded CSV files
com.github.albfernandez/juniversalchardet {:mvn/version "2.5.0"}
@@ -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.

View File

@@ -0,0 +1,52 @@
# Email template preview
A browser-based previewer for the transactional email templates in
`src/metabase/channel/email/*.hbs`, so you can work on email copy and design
without running Metabase or sending real emails.
## Run it
```
bun run email-dev
```
That starts a tiny local server and opens the preview in your browser
(default: http://localhost:8455 — set `PORT` to change it).
## Using it
- **Pick a template** in the left sidebar. The shared header/footer partials
are included automatically, just like in real emails.
- **Edit the template**: open the `.hbs` file in any editor, save, and the
preview refreshes by itself (it polls for changes every second).
- **Fill in values**: the *Fields* panel on the right lists every value the
template receives (`{{applicationName}}`, user names, links, …) as editable
inputs. The *JSON* tab shows the same data raw, for arrays and adding keys.
Edits are per-browser (localStorage); *Reset* restores the defaults.
- **Check widths** with the Desktop / Narrow / Phone buttons — email clients
are usually around 600px.
- **Copy HTML** copies the fully rendered email, e.g. to paste into an email
client testing tool like Litmus.
## What's a "sample context"?
Each email is rendered from a template plus data supplied by the backend at
send time (the "context"). The defaults in `sample-contexts.js` mirror what
the real send-time code passes, with demo values. If a template gains a new
variable, add a default for it there.
## Fidelity caveats
- Production renders with Java Handlebars (jknack); this tool uses
handlebars.js. For everything these templates use (`#if`, `#each`, `#with`,
partials, the custom helpers) the output is the same, but it is a preview,
not a byte-for-byte reproduction — QA real sends for final signoff.
- Subject lines live in Clojure code (`messages.clj` and the notification
handlers), not in the templates, so they don't appear here.
- Rendered dashboard/card images in subscription emails are placeholders —
the real content comes from the visualization renderer at send time.
- `cid:` images (inline attachments, e.g. some icons) show as broken images
in the browser; they work in real emails.
- What you see is a browser rendering. Email clients (Outlook especially)
are stricter — the preview is for copy and layout iteration, not client
compatibility testing.

View File

@@ -0,0 +1,571 @@
<!DOCTYPE html>
<!--
Email template preview tool.
Renders the Handlebars email templates in src/metabase/channel/email/ with
sample data, entirely in the browser — no backend required. Run it with:
./bin/email-preview
then edit any .hbs file in your editor; the preview reloads automatically.
Fidelity notes: production renders these templates with Java Handlebars
(jknack) — this tool uses handlebars.js, which is equivalent for everything
these templates use ({{#if}}, {{#each}}, {{#with}}, partials). The custom
helpers (equals, format-date, now, initials, card-url, dashboard-url,
trash-url) are re-implemented below; format-date approximates java-time
formatting with a common-token subset.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>Metabase Email Template Preview</title>
<script src="https://cdn.jsdelivr.net/npm/handlebars@4.7.8/dist/handlebars.min.js"></script>
<script src="sample-contexts.js"></script>
<style>
:root {
--brand: #509EE3;
--text-dark: #4C5773;
--text-medium: #949AAB;
--border: #EEECEC;
--bg: #F9FBFC;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: 'Lato', 'Helvetica Neue', Helvetica, sans-serif;
color: var(--text-dark);
display: flex;
height: 100vh;
overflow: hidden;
}
#sidebar {
width: 260px;
min-width: 260px;
border-right: 1px solid var(--border);
background: var(--bg);
overflow-y: auto;
padding: 0.5em 0;
}
#sidebar h1 {
font-size: 0.95em;
padding: 0.5em 1em;
margin: 0;
color: var(--text-dark);
}
#sidebar .group-label {
font-size: 0.7em;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-medium);
padding: 1em 1.2em 0.3em;
}
#sidebar button {
display: block;
width: 100%;
text-align: left;
border: none;
background: none;
padding: 0.45em 1.2em;
font: inherit;
font-size: 0.85em;
color: var(--text-dark);
cursor: pointer;
border-left: 3px solid transparent;
}
#sidebar button:hover { background: #EDF2F5; }
#sidebar button.active {
border-left-color: var(--brand);
background: #EDF2F5;
font-weight: bold;
color: var(--brand);
}
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
#toolbar {
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.5em 1em;
border-bottom: 1px solid var(--border);
font-size: 0.85em;
}
#toolbar .name { font-weight: bold; margin-right: auto; }
#toolbar button {
font: inherit;
padding: 0.3em 0.8em;
border: 1px solid var(--border);
border-radius: 4px;
background: white;
color: var(--text-dark);
cursor: pointer;
}
#toolbar button.on { border-color: var(--brand); color: var(--brand); font-weight: bold; }
#error {
display: none;
padding: 0.6em 1em;
background: #FDF3F3;
color: #C7484C;
font-size: 0.85em;
border-bottom: 1px solid #EFC7C7;
white-space: pre-wrap;
}
#content { flex: 1; display: flex; min-height: 0; }
#preview-pane {
flex: 1;
overflow: auto;
background: #E8EDF2;
display: flex;
justify-content: center;
padding: 1.5em;
}
#frame {
border: none;
background: white;
width: 100%;
max-width: 900px;
height: 100%;
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
transition: max-width 0.15s ease;
}
#context-pane {
width: 340px;
min-width: 340px;
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
background: var(--bg);
}
#context-pane header {
display: flex;
align-items: center;
padding: 0.5em 0.8em;
font-size: 0.8em;
color: var(--text-medium);
}
#context-pane header span { margin-right: auto; text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.85em; }
#context-pane header button {
font: inherit;
font-size: 0.95em;
border: none;
background: none;
color: var(--brand);
cursor: pointer;
}
#context {
flex: 1;
border: none;
border-top: 1px solid var(--border);
resize: none;
font-family: Menlo, Monaco, 'Courier New', monospace;
font-size: 11px;
line-height: 1.5;
padding: 0.8em;
color: var(--text-dark);
background: white;
outline: none;
}
#context.bad { background: #FDF3F3; }
#context-pane header .tab {
color: var(--text-medium);
padding: 0.1em 0.5em;
border-radius: 4px;
}
#context-pane header .tab.on { color: var(--brand); font-weight: bold; background: #EDF2F5; }
#fields {
flex: 1;
overflow-y: auto;
border-top: 1px solid var(--border);
background: white;
padding: 0.6em 0.8em 1.5em;
}
#fields label {
display: block;
font-size: 0.7em;
color: var(--text-medium);
margin: 0.9em 0 0.25em;
word-break: break-all;
}
#fields input[type=text], #fields textarea.field {
width: 100%;
font: inherit;
font-size: 0.8em;
padding: 0.35em 0.5em;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-dark);
}
#fields textarea.field { resize: vertical; min-height: 3.2em; }
#fields .note {
font-size: 0.75em;
color: var(--text-medium);
margin-top: 1.2em;
line-height: 1.5;
}
#empty {
margin: auto;
color: var(--text-medium);
text-align: center;
}
</style>
</head>
<body>
<nav id="sidebar">
<h1>📧 Email templates</h1>
<div id="template-list"></div>
<div class="group-label">Partials (shared header/footer)</div>
<div id="partial-list"></div>
</nav>
<div id="main">
<div id="toolbar">
<span class="name" id="current-name">—</span>
<button data-width="900">Desktop</button>
<button data-width="600">Narrow</button>
<button data-width="375">Phone</button>
<button id="copy-html" title="Copy rendered HTML to clipboard">Copy HTML</button>
</div>
<div id="error"></div>
<div id="content">
<div id="preview-pane">
<div id="empty">← Pick a template.<br><br>Edit the .hbs file in any editor and<br>the preview refreshes automatically.</div>
<iframe id="frame" hidden></iframe>
</div>
<div id="context-pane">
<header>
<span>Sample data</span>
<button class="tab on" data-tab="fields">Fields</button>
<button class="tab" data-tab="json">JSON</button>
<button id="reset-context" title="Discard edits, restore the default sample data">Reset</button>
</header>
<div id="fields"></div>
<textarea id="context" spellcheck="false" hidden></textarea>
</div>
</div>
</div>
<script>
'use strict';
const TEMPLATE_DIR = '/src/metabase/channel/email/';
const POLL_MS = 1000;
// Used only if the server doesn't provide a directory listing.
const FALLBACK_TEMPLATES = [
'_dashsub_alert_header', '_footer', '_header', '_logo', '_notification_card_condition',
'broken_subscription_notification', 'card_notification_archived', 'card_notification_changed_stopped',
'comment_created', 'creator_sentiment_email', 'dashboard_subscription', 'follow_up_email',
'login_from_new_device', 'mfa_disabled', 'mfa_enabled', 'mfa_login_code', 'mfa_removed_by_admin',
'new_user_invite', 'notification_card', 'notification_card_new_confirmation',
'notification_card_unsubscribed', 'notification_card_you_were_added', 'notification_card_you_were_removed',
'password_reset', 'persisted-model-error', 'security_advisory', 'slack_token_error',
'support_access_grant', 'transform_failed', 'transform_failure_digest', 'user_joined_notification',
'warn_deprecate_pulse',
];
// ---- Custom helpers, mirroring metabase.channel.template.handlebars-helper ----
const SITE_URL = (window.COMMON_CONTEXT && window.COMMON_CONTEXT.siteUrl) || 'http://localhost:3000';
Handlebars.registerHelper('equals', (x, y) => x === y || String(x) === String(y));
Handlebars.registerHelper('now', () => new Date());
Handlebars.registerHelper('initials', (name) => {
const m = String(name || '').match(/\b\w/g);
return m ? m.slice(0, 2).join('').toUpperCase() : '';
});
Handlebars.registerHelper('card-url', (id) => `${SITE_URL}/question/${id}`);
Handlebars.registerHelper('dashboard-url', (id) => `${SITE_URL}/dashboard/${id}`);
Handlebars.registerHelper('trash-url', () => `${SITE_URL}/trash`);
// Approximates java-time formatting for the common tokens.
Handlebars.registerHelper('format-date', (date, fmt) => {
const d = date instanceof Date ? date : new Date(date);
if (isNaN(d)) return String(date);
const pad = (n) => String(n).padStart(2, '0');
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const tokens = {
yyyy: d.getFullYear(), YYYY: d.getFullYear(),
MMMM: MONTHS[d.getMonth()], MMM: MONTHS[d.getMonth()].slice(0, 3), MM: pad(d.getMonth() + 1),
dd: pad(d.getDate()), d: d.getDate(),
HH: pad(d.getHours()), h: ((d.getHours() + 11) % 12) + 1,
mm: pad(d.getMinutes()), a: d.getHours() < 12 ? 'AM' : 'PM',
};
if (typeof fmt !== 'string') return d.toLocaleString();
return fmt.replace(/yyyy|YYYY|MMMM|MMM|MM|dd|d|HH|h|mm|a/g, (t) => tokens[t]);
});
// ---- State ----
let allNames = [];
let currentName = null;
let sources = {}; // name -> .hbs source text
let lastRenderedHtml = '';
let contextDebounce = null;
const $ = (sel) => document.querySelector(sel);
const isPartial = (name) => name.startsWith('_');
const partials = () => allNames.filter(isPartial);
// ---- Template discovery & fetching ----
async function fetchSource(name) {
const res = await fetch(TEMPLATE_DIR + name + '.hbs', { cache: 'no-store' });
if (!res.ok) throw new Error(`Could not load ${name}.hbs (HTTP ${res.status})`);
return res.text();
}
async function discoverTemplates() {
// Served by dev/email-preview/serve.ts (bun run email-dev).
try {
const res = await fetch('/templates.json', { cache: 'no-store' });
if (res.ok) {
const names = await res.json();
if (Array.isArray(names) && names.length) return names.sort();
}
} catch (e) { /* fall through */ }
// Any static server with directory listings (e.g. python3 -m http.server).
try {
const res = await fetch(TEMPLATE_DIR, { cache: 'no-store' });
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
const names = [...doc.querySelectorAll('a')]
.map((a) => a.getAttribute('href') || '')
.filter((h) => h.endsWith('.hbs'))
.map((h) => decodeURIComponent(h.split('/').pop().replace(/\.hbs$/, '')));
if (names.length) return names.sort();
} catch (e) { /* fall through */ }
return FALLBACK_TEMPLATES;
}
// ---- Context handling ----
function deepMerge(a, b) {
if (Array.isArray(a) || Array.isArray(b) || typeof a !== 'object' || typeof b !== 'object' || !a || !b) {
return b === undefined ? a : b;
}
const out = { ...a };
for (const k of Object.keys(b)) out[k] = deepMerge(a[k], b[k]);
return out;
}
function defaultContext(name) {
const sample = (window.SAMPLE_CONTEXTS || {})[name] || {};
return deepMerge(window.COMMON_CONTEXT || {}, sample);
}
const storageKey = (name) => `email-preview:ctx:${name}`;
function loadContextText(name) {
return localStorage.getItem(storageKey(name))
|| JSON.stringify(defaultContext(name), null, 2);
}
function currentContext() {
return JSON.parse($('#context').value);
}
// ---- Fields form: a simple fill-in-the-blanks view over the same JSON ----
function leafPaths(obj, prefix = []) {
const out = [];
for (const [k, v] of Object.entries(obj || {})) {
const path = [...prefix, k];
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
out.push(...leafPaths(v, path));
} else {
out.push({ path, value: v });
}
}
return out;
}
function setPath(obj, path, value) {
let o = obj;
for (const k of path.slice(0, -1)) o = o[k] ?? (o[k] = {});
o[path[path.length - 1]] = value;
}
let fieldDebounce = null;
function updateFromField(path, value) {
clearTimeout(fieldDebounce);
fieldDebounce = setTimeout(() => {
let ctx;
try { ctx = currentContext(); } catch (e) { return; }
setPath(ctx, path, value);
$('#context').value = JSON.stringify(ctx, null, 2);
localStorage.setItem(storageKey(currentName), $('#context').value);
render();
}, 300);
}
function buildFieldsForm() {
const el = $('#fields');
el.innerHTML = '';
let ctx;
try {
ctx = currentContext();
} catch (e) {
el.innerHTML = '<div class="note">The JSON tab has a syntax error — fix it there first.</div>';
return;
}
for (const { path, value } of leafPaths(ctx)) {
const label = document.createElement('label');
label.textContent = path.join(' › ');
el.appendChild(label);
let input;
if (typeof value === 'boolean') {
input = document.createElement('input');
input.type = 'checkbox';
input.checked = value;
input.addEventListener('change', () => updateFromField(path, input.checked));
} else if (Array.isArray(value)) {
input = document.createElement('textarea');
input.className = 'field';
input.value = JSON.stringify(value, null, 2);
input.addEventListener('input', () => {
try { updateFromField(path, JSON.parse(input.value)); } catch (e) { /* keep typing */ }
});
} else {
const isLong = typeof value === 'string' && value.length > 60;
input = document.createElement(isLong ? 'textarea' : 'input');
if (isLong) { input.className = 'field'; } else { input.type = 'text'; }
input.value = value == null ? '' : String(value);
const wasNumber = typeof value === 'number';
input.addEventListener('input', () =>
updateFromField(path, wasNumber && input.value !== '' && !isNaN(input.value) ? Number(input.value) : input.value));
}
el.appendChild(input);
}
el.insertAdjacentHTML('beforeend',
'<div class="note">These are demo values — they only affect this preview, never real emails. ' +
'For arrays or new keys, use the JSON tab. Edits are saved in your browser; Reset restores the defaults.</div>');
}
// ---- Rendering ----
function showError(msg) {
const el = $('#error');
el.textContent = msg;
el.style.display = 'block';
}
function clearError() { $('#error').style.display = 'none'; }
function render() {
if (!currentName) return;
let ctx;
try {
ctx = currentContext();
$('#context').classList.remove('bad');
} catch (e) {
$('#context').classList.add('bad');
showError('Sample data is not valid JSON: ' + e.message);
return;
}
try {
for (const p of partials()) Handlebars.registerPartial(p, sources[p] ?? '');
const template = Handlebars.compile(sources[currentName] ?? '');
lastRenderedHtml = template(ctx);
clearError();
} catch (e) {
showError('Template error: ' + e.message);
return;
}
const frame = $('#frame');
const scrollY = frame.contentWindow ? frame.contentWindow.scrollY : 0;
frame.onload = () => frame.contentWindow.scrollTo(0, scrollY);
frame.srcdoc = lastRenderedHtml;
frame.hidden = false;
$('#empty').style.display = 'none';
}
// ---- Selection ----
async function selectTemplate(name) {
currentName = name;
$('#current-name').textContent = name + '.hbs';
document.querySelectorAll('#sidebar button').forEach((b) =>
b.classList.toggle('active', b.dataset.name === name));
$('#context').value = loadContextText(name);
buildFieldsForm();
const needed = [name, ...partials()];
await Promise.all(needed.map(async (n) => { sources[n] = await fetchSource(n); }));
render();
history.replaceState(null, '', '#' + name);
}
// ---- Live reload: poll the current template + partials for changes ----
setInterval(async () => {
if (!currentName) return;
let changed = false;
for (const n of [currentName, ...partials()]) {
try {
const text = await fetchSource(n);
if (text !== sources[n]) { sources[n] = text; changed = true; }
} catch (e) { /* transient; keep last good copy */ }
}
if (changed) render();
}, POLL_MS);
// ---- UI wiring ----
$('#context').addEventListener('input', () => {
clearTimeout(contextDebounce);
contextDebounce = setTimeout(() => {
localStorage.setItem(storageKey(currentName), $('#context').value);
render();
}, 300);
});
$('#reset-context').addEventListener('click', () => {
localStorage.removeItem(storageKey(currentName));
$('#context').value = loadContextText(currentName);
buildFieldsForm();
render();
});
document.querySelectorAll('#context-pane .tab').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('#context-pane .tab').forEach((b) => b.classList.remove('on'));
btn.classList.add('on');
const showFields = btn.dataset.tab === 'fields';
$('#fields').hidden = !showFields;
$('#context').hidden = showFields;
if (showFields) buildFieldsForm(); // pick up any JSON-tab edits
});
});
document.querySelectorAll('#toolbar button[data-width]').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('#toolbar button[data-width]').forEach((b) => b.classList.remove('on'));
btn.classList.add('on');
$('#frame').style.maxWidth = btn.dataset.width + 'px';
});
});
$('#copy-html').addEventListener('click', async () => {
await navigator.clipboard.writeText(lastRenderedHtml);
$('#copy-html').textContent = 'Copied!';
setTimeout(() => { $('#copy-html').textContent = 'Copy HTML'; }, 1200);
});
// ---- Boot ----
(async function init() {
if (typeof Handlebars === 'undefined') {
showError('Could not load handlebars.js from the CDN — this tool needs an internet connection.');
return;
}
allNames = await discoverTemplates();
const list = $('#template-list');
const plist = $('#partial-list');
for (const name of allNames) {
const btn = document.createElement('button');
btn.textContent = name;
btn.dataset.name = name;
btn.addEventListener('click', () => selectTemplate(name));
(isPartial(name) ? plist : list).appendChild(btn);
}
const fromHash = location.hash.slice(1);
if (fromHash && allNames.includes(fromHash)) selectTemplate(fromHash);
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,407 @@
/*
* Default sample data for the email template preview tool (index.html).
*
* COMMON_CONTEXT mirrors `common-context` in metabase.channel.email.messages —
* values every template receives. SAMPLE_CONTEXTS holds per-template extras,
* shaped to match what each template's real render call site passes (see the
* render calls in messages.clj, impl/email.clj, and the notification handlers
* seeded in notification/seed.clj).
*
* These are demo values only — the preview never sends email. Tweak freely;
* per-browser edits live in localStorage, this file is just the defaults.
*/
const BUTTON_STYLE =
"display: inline-block; box-sizing: border-box; padding: 0.5rem 1.375rem; " +
"font-size: 1.063rem; font-weight: bold; text-decoration: none; cursor: pointer; " +
"color: #fff; border: 1px solid #509EE3; background-color: #509EE3; border-radius: 4px;";
// Templates rendered through the notification pipeline get a snake_case
// `context` map instead of the camelCase top-level keys.
const NOTIFICATION_CONTEXT = {
application_name: "Metabase",
application_color: "#509EE3",
application_logo_url: "http://static.metabase.com/email_logo.png",
site_url: "http://localhost:3000",
};
const NOTIFICATION_STYLE = {
color_text_dark: "#4C5773",
color_text_medium: "#696E7B",
color_text_light: "#949AAB",
};
const CARD_PLACEHOLDER = (name) =>
`<div style="padding:2em;background:#f9fbfc;border:1px dashed #ccc;text-align:center;color:#949AAB;margin-bottom:1em;">[rendered card: ${name}]</div>`;
window.COMMON_CONTEXT = {
applicationName: "Metabase",
applicationColor: "#509EE3",
applicationLogoUrl: "http://static.metabase.com/email_logo.png",
buttonStyle: BUTTON_STYLE,
colorTextLight: "#B8BBC3",
colorTextMedium: "#949AAB",
colorTextDark: "#4C5773",
siteUrl: "http://localhost:3000",
};
window.SAMPLE_CONTEXTS = {
// ---- Account & auth ----
mfa_enabled: {},
mfa_disabled: {},
mfa_removed_by_admin: {},
mfa_login_code: { code: "836114" },
password_reset: {
emailType: "password_reset",
logoHeader: true,
google: false,
nonGoogleSSO: false,
isActive: true,
passwordResetUrl: "http://localhost:3000/auth/reset_password/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
adminEmail: "admin@example.com",
adminEmailSet: true,
},
login_from_new_device: {
context: NOTIFICATION_CONTEXT,
payload: { style: { color_text_dark: "#4C5773" } },
"first-name": "Meredith",
device: "Chrome (Mac OS X 10.15)",
location: "Oakland, California",
timestamp: "Friday, July 10, 2026 at 9:14 AM (PDT)",
},
new_user_invite: {
context: NOTIFICATION_CONTEXT,
payload: {
event_info: {
details: { invitor: { first_name: "Meredith", email: "meredith@example.com" } },
object: { is_from_setup: false },
},
custom: {
user_invited_join_url: "http://localhost:3000/auth/join/a1b2c3d4-e5f6-7890",
// Set a name here (and optionally invite_target_is_dashboard) to
// preview the "wants to share a question/dashboard" variant.
invite_target_name: "",
invite_target_is_dashboard: false,
},
},
},
user_joined_notification: {
context: NOTIFICATION_CONTEXT,
payload: { style: { color_text_dark: "#4C5773" } },
joinedUserName: "Oscar",
joinedViaSSO: false,
joinedUserEmail: "oscar@example.com",
joinedDate: "Friday, July 10",
adminEmail: "admin@example.com",
joinedUserEditUrl: "http://localhost:3000/admin/people",
},
// ---- Surveys & follow-ups ----
follow_up_email: {
emailType: "notification",
logoHeader: true,
heading: "We hope you've been enjoying Metabase.",
callToAction: "Would you mind taking a quick 5 minute survey to tell us how it's going?",
link: "https://metabase.com/feedback/active",
},
creator_sentiment_email: {
emailType: "notification",
logoHeader: true,
"first-name": "Meredith",
link: "https://metabase.com/feedback/creator",
"self-hosted": "http://localhost:3000",
},
// ---- Subscriptions & alerts ----
dashboard_subscription: {
context: { ...NOTIFICATION_CONTEXT, include_branding: true },
creator: { common_name: "Meredith Palmer" },
payload: {
dashboard: {
id: 12,
name: "Quarterly Revenue Overview",
description: "<p>Key revenue metrics, refreshed daily for the leadership team.</p>",
},
parameters: [
{ id: "a1b2c3d4", name: "Date Range", value: "past30days" },
{ id: "e5f6a7b8", name: "Category", value: "Gadget" },
],
dashboard_subscription: { id: 301, disable_links: false },
style: NOTIFICATION_STYLE,
},
computed: {
icon_cid: "dashboard-icon@metabase", // cid: inline attachment; shows broken in browser
dashboard_has_tabs: false,
dashboard_content: CARD_PLACEHOLDER("Monthly Orders") + CARD_PLACEHOLDER("Revenue by Region"),
filters:
"<table cellpadding='0' cellspacing='0'><tr>" +
"<td style='font-size:12px;color:#949AAB;padding-right:12px;'><strong style='color:#696E7B;'>Date Range:</strong> Past 30 days</td>" +
"<td style='font-size:12px;color:#949AAB;'><strong style='color:#696E7B;'>Category:</strong> Gadget</td>" +
"</tr></table>",
management_text: "Manage your subscriptions",
management_url: "http://localhost:3000/account/notifications",
},
},
notification_card: {
context: { ...NOTIFICATION_CONTEXT, include_branding: true },
creator: { common_name: "Meredith Palmer" },
payload: {
card: { id: 42, name: "Monthly Orders" },
notification_card: { send_condition: "goal_above", send_once: true, disable_links: false },
style: NOTIFICATION_STYLE,
},
computed: {
icon_cid: "bell-icon@metabase", // cid: inline attachment; shows broken in browser
content: CARD_PLACEHOLDER("Monthly Orders"),
alert_schedule: "Run daily at 9:00 AM",
goal_value: 5000,
management_text: "Manage your subscriptions",
management_url: "http://localhost:3000/account/notifications",
},
},
notification_card_new_confirmation: {
emailType: "notification",
logoHeader: true,
payload: {
event_info: {
object: {
payload: {
card_id: 42,
card: { name: "Monthly Orders" },
send_condition: "has_result",
disable_links: false,
},
},
},
},
},
notification_card_unsubscribed: {
emailType: "notification",
logoHeader: true,
payload: { card_id: 42, card: { name: "Monthly Orders" } },
},
notification_card_you_were_added: {
emailType: "notification",
logoHeader: true,
payload: { card_id: 42, card: { name: "Monthly Orders" }, send_condition: "has_result" },
},
notification_card_you_were_removed: {
emailType: "notification",
logoHeader: true,
actor_name: "Meredith Palmer",
payload: { card_id: 42, card: { name: "Monthly Orders" }, disable_links: false },
},
card_notification_archived: {
emailType: "notification",
logoHeader: true,
card: { id: 42, name: "Monthly Orders" },
actor: { first_name: "Meredith", last_name: "Palmer" },
disable_links: false,
},
card_notification_changed_stopped: {
emailType: "notification",
logoHeader: true,
card: { id: 42, name: "Monthly Orders" },
actor: { first_name: "Meredith", last_name: "Palmer" },
disable_links: false,
},
broken_subscription_notification: {
emailType: "notification",
logoHeader: true,
dashboardName: "Quarterly Revenue Overview",
dashboardUrl: "http://localhost:3000/dashboard/12",
disable_links: false,
badParameters: [
{ name: "Region", value: "West or Midwest" },
{ name: "Product Category", value: "Gadget" },
],
affectedUsers: [
{ "notification-type": "email", recipient: "Meredith Palmer", role: "Dashboard Creator" },
{ "notification-type": "email", recipient: "Oscar Nunez", role: "Subscription Creator" },
],
},
warn_deprecate_pulse: {
emailType: "notification",
logoHeader: true,
userName: "Meredith Palmer",
instanceURL: "http://localhost:3000",
pulses: [
{ name: "Weekly Sales Pulse", url: "http://localhost:3000/pulse/23" },
{ name: "Support Ticket Volume", url: "http://localhost:3000/pulse/31" },
],
},
// ---- Comments & collaboration ----
comment_created: {
context: NOTIFICATION_CONTEXT,
payload: {
style: { color_text_dark: "#4C5773" },
event_info: {
author: "Oscar Nunez",
parent_author: "Meredith Palmer",
entity_type: "document",
entity_title: "Q3 Planning Notes",
document_href: "/document/87",
comment_href: "/document/87?comment=214",
parent_comment: "<p>Should we fold the churn analysis into this doc, or keep it separate?</p>",
comment: "<p>Let's fold it in — I'll add a section under <strong>Retention</strong> this afternoon.</p>",
},
},
},
// ---- Admin & ops ----
security_advisory: {
emailType: "notification",
logoHeader: false,
context: {
...NOTIFICATION_CONTEXT,
style: { button: BUTTON_STYLE },
},
payload: {
custom: {
severity_label: "Critical",
severity_color: "#E65050",
status_label: "Active",
security_center_url: "http://localhost:3000/admin/tools/security-center",
},
event_info: {
object: {
title: "SQL injection in embedded question parameters",
description:
"A vulnerability was identified that could allow specially crafted embedded question parameters " +
"to execute arbitrary SQL against connected databases. Your instance version matches the affected range.",
remediation: "Upgrade to Metabase 55.8.2 or later, or disable static embedding until you can upgrade.",
advisory_url: "https://www.metabase.com/security/advisories/MB-2026-0042",
},
},
},
},
slack_token_error: {
emailType: "notification",
logoHeader: false,
context: {
...NOTIFICATION_CONTEXT,
style: { button: BUTTON_STYLE },
},
},
support_access_grant: {
context: NOTIFICATION_CONTEXT,
payload: {
style: { color_text_dark: "#4C5773" },
event_info: {
duration_minutes: 120,
grant_end_time: "2026-07-10T21:14:00Z",
notes:
"Investigating slow dashboard loads reported by the customer.\n" +
"Access limited to admin settings and query diagnostics.",
ticket_number: "SUP-18342",
password_reset_url: "http://localhost:3000/auth/reset_password/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
},
},
},
"persisted-model-error": {
"database-name": "Sample Database",
errors: [
{
"is-not-first": false,
error: "Table 'orders_source' not found; it may have been renamed or dropped.",
"card-id": 42,
"card-name": "Monthly Orders",
"collection-name": "Finance",
"last-run-at": "July 10, 2026, 3:10 AM PDT",
"last-run-trigger": "Scheduled",
"card-url": "http://localhost:3000/question/42",
"collection-url": "http://localhost:3000/collection/7",
"caching-log-details-url": "http://localhost:3000/admin/tools/model-caching/101",
},
{
"is-not-first": true,
error: "Query timed out after 600 seconds.",
"card-id": 43,
"card-name": "Customer LTV",
"collection-name": "Finance",
"last-run-at": "July 10, 2026, 3:12 AM PDT",
"last-run-trigger": "Scheduled",
"card-url": "http://localhost:3000/question/43",
"collection-url": "http://localhost:3000/collection/7",
"caching-log-details-url": "http://localhost:3000/admin/tools/model-caching/102",
},
],
},
transform_failed: {
context: NOTIFICATION_CONTEXT,
payload: {
style: { color_text_dark: "#4C5773" },
event_info: {
job_name: "Nightly Warehouse Refresh",
job_href: "http://localhost:3000/admin/transforms/jobs/7",
failures: [
{
transform_name: "Orders Enriched",
transform_href: "http://localhost:3000/admin/transforms/15/runs",
message: {
first_line: 'Column "discount_pct" does not exist in source table.',
details: [
"at stage 2 of 3 (join with promotions)",
"last successful run: July 9, 2026, 2:00 AM",
],
},
},
{
transform_name: "Customer LTV Rollup",
transform_href: "http://localhost:3000/admin/transforms/16/runs",
message: { first_line: "Query timed out after 600 seconds.", details: [] },
},
],
},
},
},
transform_failure_digest: {
context: NOTIFICATION_CONTEXT,
payload: {
style: { color_text_dark: "#4C5773" },
event_info: {
job_count: 2,
failure_count: 5,
jobs: [
{
job_name: "Nightly Warehouse Refresh",
job_href: "http://localhost:3000/admin/transforms/jobs/7",
failure_count: 3,
latest_error: 'Column "discount_pct" does not exist in source table.',
},
{
job_name: "Hourly Events Rollup",
job_href: "http://localhost:3000/admin/transforms/jobs/9",
failure_count: 2,
latest_error: "Query timed out after 600 seconds.",
},
],
},
},
},
};

View File

@@ -0,0 +1,60 @@
/**
* Dev server for the email template preview tool.
*
* bun run email-dev # serves on :8455 and opens the browser
* PORT=9000 bun run email-dev
*
* Serves dev/email-preview/index.html, exposes the list of templates at
* /templates.json, and serves the .hbs files themselves so the page can
* fetch (and live-reload) them.
*/
import { readdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
const PREVIEW_DIR = `${REPO_ROOT}dev/email-preview/`;
const TEMPLATE_DIR = `${REPO_ROOT}src/metabase/channel/email/`;
const PORT = Number(process.env.PORT || 8455);
const server = Bun.serve({
port: PORT,
async fetch(req) {
const pathname = decodeURIComponent(new URL(req.url).pathname);
if (pathname.includes("..")) {
return new Response("bad path", { status: 400 });
}
if (pathname === "/" || pathname === "/index.html") {
return new Response(Bun.file(`${PREVIEW_DIR}index.html`));
}
if (pathname === "/sample-contexts.js") {
return new Response(Bun.file(`${PREVIEW_DIR}sample-contexts.js`));
}
if (pathname === "/templates.json") {
const names = (await readdir(TEMPLATE_DIR))
.filter((f) => f.endsWith(".hbs"))
.map((f) => f.replace(/\.hbs$/, ""))
.sort();
return Response.json(names);
}
// .hbs sources, fetched by the page as /src/metabase/channel/email/<name>.hbs
const file = Bun.file(REPO_ROOT + pathname.slice(1));
if (await file.exists()) {
return new Response(file, {
headers: { "cache-control": "no-store" },
});
}
return new Response("not found", { status: 404 });
},
});
const url = `http://localhost:${server.port}/`;
console.log(`📧 Email template preview → ${url}`);
console.log(` Templates: src/metabase/channel/email/*.hbs (edits reload live)`);
if (!process.env.EMAIL_DEV_NO_OPEN) {
const opener =
process.platform === "darwin" ? "open"
: process.platform === "win32" ? "start"
: "xdg-open";
Bun.spawn([opener, url], { stdout: "ignore", stderr: "ignore" });
}

View File

@@ -166,7 +166,7 @@ Use the dev namespace to see what the config should contain:
```clojure
(dev.model-boundary-config/compute-model-boundaries) ;; returns data
(dev.model-boundary-config/update-config!) ;; rewrite config.edn
(dev.modules-config/update-config!) ;; rewrite config.edn
```
## Limitations and future directions

View File

@@ -0,0 +1,44 @@
;;
;; Synthetic modules config used by dev.modules-config-test. Small, but structurally a full config:
;; a header comment block plus a `:metabase/modules` map. Each module here is shaped to exercise a
;; different branch of the rewriter (sentinel preservation, resorting, append, removal, comment
;; preservation, and the library-module skip). See the test for the intent behind each.
;;
{:metabase/modules
{alpha
{:team "Test Team"
;; :api is a sentinel — must survive even though a real :api is computed for alpha.
:api :any
;; unsorted + carries an element comment; rewriting to sorted order drops the comment.
:uses #{beta gamma}
;; stale: missing :model/Gamma, must be rewritten.
:model-imports #{:model/Beta :model/Gamma}
:model-exports #{:model/Alpha}}
beta
{:team "Test Team"
;; :api missing entirely — must be appended.
:uses #{}
:model-exports #{:model/Beta}
:api #{metabase.beta.core}}
connection-pool
{:team "Test Team"
;; Library-sourced module absent from the deps graph; the rewriter must leave it untouched.
:api #{metabase.connection-pool}}
gamma
{:team "Test Team"
:api :any
:uses :any
;; bypass sentinel — preserved; also means gamma drives no model-imports.
:model-imports :bypass
:model-exports #{:model/Gamma}}
enterprise/zeta
{:team "Test Team"
:api #{}
;; already correct + sorted; the element comment must be preserved untouched.
:uses #{alpha ; core dependency
beta}
:model-imports #{:model/Alpha}}}}

View File

@@ -0,0 +1,42 @@
;;
;; Synthetic modules config used by dev.modules-config-test. Small, but structurally a full config:
;; a header comment block plus a `:metabase/modules` map. Each module here is shaped to exercise a
;; different branch of the rewriter (sentinel preservation, resorting, append, removal, comment
;; preservation, and the library-module skip). See the test for the intent behind each.
;;
{:metabase/modules
{alpha
{:team "Test Team"
;; :api is a sentinel — must survive even though a real :api is computed for alpha.
:api :any
;; unsorted + carries an element comment; rewriting to sorted order drops the comment.
:uses #{gamma ; used heavily
beta}
;; stale: missing :model/Gamma, must be rewritten.
:model-imports #{:model/Beta}}
beta
{:team "Test Team"
;; :api missing entirely — must be appended.
:uses #{}
:model-exports #{:model/Beta}}
connection-pool
{:team "Test Team"
;; Library-sourced module absent from the deps graph; the rewriter must leave it untouched.
:api #{metabase.connection-pool}}
gamma
{:team "Test Team"
:api :any
:uses :any
;; bypass sentinel — preserved; also means gamma drives no model-imports.
:model-imports :bypass}
enterprise/zeta
{:team "Test Team"
:api #{}
;; already correct + sorted; the element comment must be preserved untouched.
:uses #{alpha ; core dependency
beta}
:model-exports #{:model/Zeta}}}}

View File

@@ -0,0 +1,36 @@
;;
;; Mock inputs for dev.modules-config-test — the four values dev.modules-config/compute-desired takes.
;; Shaped like the real dev.deps-graph outputs, but tiny and hand-authored so the expected output is
;; predictable. `:config` mirrors (kondo-config): connection-pool is intentionally absent (the deps graph
;; dissocs it), which is what makes the rewriter leave that module untouched.
;;
{:config
{alpha {}
beta {}
gamma {:model-imports :bypass}
enterprise/zeta {}}
;; one entry per source file: {:module M :namespace NS :deps [{:module M :namespace NS} ...]}
:dependencies
[{:module alpha :namespace metabase.alpha.core
:deps [{:module beta :namespace metabase.beta.core}
{:module gamma :namespace metabase.gamma.core}]}
{:module beta :namespace metabase.beta.core :deps []}
{:module gamma :namespace metabase.gamma.core :deps []}
{:module enterprise/zeta :namespace metabase-enterprise.zeta.core
:deps [{:module alpha :namespace metabase.alpha.core}
{:module beta :namespace metabase.beta.core}]}]
;; {:model/X => owning module}
:model-ownership
{:model/Alpha alpha
:model/Beta beta
:model/Gamma gamma
:model/Zeta enterprise/zeta}
;; {module => #{models it references}}
:model-references
{alpha #{:model/Alpha :model/Beta :model/Gamma}
beta #{:model/Beta}
gamma #{:model/Gamma :model/Alpha}
enterprise/zeta #{:model/Zeta :model/Alpha}}}

View File

@@ -9,13 +9,9 @@
To update config.edn with computed model boundaries:
(dev.model-boundary-config/update-config!)"
(dev.modules-config/update-config!)"
(:require
[clojure.string :as str]
[dev.deps-graph :as deps-graph]
[rewrite-clj.node :as r.node]
[rewrite-clj.parser :as r.parser]
[rewrite-clj.zip :as z]))
[dev.deps-graph :as deps-graph]))
(set! *warn-on-reflection* true)
@@ -29,133 +25,51 @@
`:model-imports` for module M = models owned by other modules that M references.
Modules with `:model-imports :bypass` are excluded: they don't need computed imports, and their
references don't drive exports (models used only by bypass modules need not be exported)."
[]
(let [config (deps-graph/kondo-config)
ownership (deps-graph/model-ownership)
module-refs (deps-graph/model-references-by-module)
all-modules (set (keys config))
bypass-modules (into #{}
(keep (fn [[mod mod-config]]
(when (= (:model-imports mod-config) :bypass)
mod)))
config)
;; {:model/X => #{modules that reference it}} — inverted index for fast export lookups
;; Only non-bypass modules drive exports.
model->referencing-modules
(reduce-kv (fn [acc mod models]
(if (contains? bypass-modules mod)
acc
(reduce (fn [acc model]
(update acc model (fnil conj #{}) mod))
acc
models)))
{}
module-refs)]
{:model-exports
(into (sorted-map)
(for [mod all-modules
:let [owned (into #{} (comp (filter (fn [[_ owner]] (= owner mod))) (map key)) ownership)]
:when (seq owned)]
[mod (into (sorted-set)
(for [model owned
:let [refs (get model->referencing-modules model)]
:when (some #(not= % mod) refs)]
model))]))
:model-imports
(into (sorted-map)
(for [mod all-modules
:when (not (contains? bypass-modules mod))
:let [imported (into (sorted-set)
(for [model (get module-refs mod)
:let [defining-mod (get ownership model)]
:when (and defining-mod (not= defining-mod mod))]
model))]
:when (seq imported)]
[mod imported]))}))
references don't drive exports (models used only by bypass modules need not be exported).
(def ^:private config-path ".clj-kondo/config/modules/config.edn")
(def ^:private set-indent
"Indentation for multi-line model sets — aligns with existing config.edn style."
(apply str (repeat 20 \space)))
(defn- model-set-str
"Build a string representation of a sorted set of model keywords.
Short sets (<=3 items) stay on one line; longer sets get one item per line."
[models]
(let [sorted (sort models)]
(if (<= (count sorted) 3)
(str "#{" (str/join " " sorted) "}")
(str "#{" (str/join (str "\n" set-indent) sorted) "}"))))
(defn- find-module-config
"Navigate from `root` to the config map for `module-sym`. Returns a zipper or nil."
[root module-sym]
(let [mods-zloc (-> (z/of-node root)
(z/find-value z/next :metabase/modules)
z/right)]
(z/find-value (z/down mods-zloc) z/right module-sym)))
(defn- find-key-value
"Find the value zipper for keyword `k` inside a map zipper, or nil."
[map-zloc k]
(some-> (z/find-value (z/down map-zloc) z/right k)
z/right))
(defn update-config!
"Compute model boundaries and update config.edn with `:model-exports` and `:model-imports` for all modules.
Only updates keys that already exist in the config — does not add new keys.
Uses rewrite-clj to manipulate the AST directly, preserving formatting."
[]
(let [boundaries (compute-model-boundaries)
root-zloc (z/of-node (r.parser/parse-file-all config-path))
modules-zloc (-> root-zloc
(z/find-value z/next :metabase/modules)
z/right)
module-syms (loop [zloc (z/down modules-zloc), acc []]
(if-not zloc
acc
(recur (some-> zloc z/right z/right)
(conj acc (z/sexpr zloc)))))
updated-root
(reduce
(fn [root module-sym]
(reduce
(fn [root config-key]
(let [mod-cfg (some-> (find-module-config root module-sym) z/right)
val-zloc (when mod-cfg (find-key-value mod-cfg config-key))
computed (get-in boundaries [config-key module-sym] #{})]
(cond
;; Key exists with a non-set value (e.g. :bypass, :any) — leave it alone.
(and val-zloc (not (set? (z/sexpr val-zloc))))
root
;; Key exists with a set value — replace or remove.
val-zloc
(if (seq computed)
(z/root (z/replace val-zloc (r.parser/parse-string (model-set-str computed))))
(-> val-zloc z/remove z/remove z/root))
;; Key missing and nothing to add.
(empty? computed)
root
;; Key missing — append `<newline><indent>:key <set>` to the module config map,
;; splicing new children directly to avoid the separator spaces `append-child` inserts.
:else
(let [m-node (z/node mod-cfg)
new-children (concat (r.node/children m-node)
[(r.node/newlines 1)
(r.node/spaces 3)
(r.node/keyword-node config-key)
(r.node/spaces 1)
(r.parser/parse-string (model-set-str computed))])]
(z/root (z/replace mod-cfg (r.node/replace-children m-node new-children)))))))
root
[:model-exports :model-imports]))
(z/root root-zloc)
module-syms)]
(spit config-path (z/root-string (z/of-node updated-root)))
(println "Updated" config-path "with :model-exports and :model-imports.")))
The 3-arity variant takes precomputed `config`, `ownership`, and `module-refs` so callers that already
have them (e.g. a single shared parse pass) can avoid re-parsing every source file."
([]
(compute-model-boundaries (deps-graph/kondo-config)
(deps-graph/model-ownership)
(deps-graph/model-references-by-module)))
([config ownership module-refs]
(let [all-modules (set (keys config))
bypass-modules (into #{}
(keep (fn [[mod mod-config]]
(when (= (:model-imports mod-config) :bypass)
mod)))
config)
;; {:model/X => #{modules that reference it}} — inverted index for fast export lookups
;; Only non-bypass modules drive exports.
model->referencing-modules
(reduce-kv (fn [acc mod models]
(if (contains? bypass-modules mod)
acc
(reduce (fn [acc model]
(update acc model (fnil conj #{}) mod))
acc
models)))
{}
module-refs)]
{:model-exports
(into (sorted-map)
(for [mod all-modules
:let [owned (into #{} (comp (filter (fn [[_ owner]] (= owner mod))) (map key)) ownership)]
:when (seq owned)]
[mod (into (sorted-set)
(for [model owned
:let [refs (get model->referencing-modules model)]
:when (some #(not= % mod) refs)]
model))]))
:model-imports
(into (sorted-map)
(for [mod all-modules
:when (not (contains? bypass-modules mod))
:let [imported (into (sorted-set)
(for [model (get module-refs mod)
:let [defining-mod (get ownership model)]
:when (and defining-mod (not= defining-mod mod))]
model))]
:when (seq imported)]
[mod imported]))})))

View File

@@ -0,0 +1,308 @@
(ns dev.modules-config
"Generate/repair `.clj-kondo/config/modules/config.edn` so it passes `metabase.core.modules-test`.
The heavy lifting already lives in [[dev.deps-graph]] (which computes the correct `:api` and `:uses`
for every module) and [[dev.model-boundary-config]] (which computes `:model-exports`/`:model-imports`).
This namespace ties them together into a single writer that rewrites the four *generated* keys in place,
sorted, while preserving everything a human owns: `:team`, `:friends`, header/footer comments, inline
`;;` annotations on set elements, the `:ignored-namespace-patterns` key, and the sentinel values
`:any`/`:bypass`.
Usage:
(dev.modules-config/update-config!) ; from a warm REPL — fast
Or from a cold JVM (see `./bin/mage fix-modules-config`):
clojure -X:dev dev.modules-config/-main
Scope: this auto-fixes the *content and ordering* of `:api`, `:uses`, `:model-exports`, and
`:model-imports` for modules that already exist in the config. Structural changes — adding a brand new
module (which needs a human-assigned `:team`), removing a stale one, or reordering modules — are only
*reported* as warnings, never performed, since they require judgement this tool doesn't have."
(:require
[clojure.string :as str]
[dev.deps-graph :as deps-graph]
[dev.model-boundary-config :as mbc]
[rewrite-clj.node :as n]
[rewrite-clj.parser :as r.parser]
[rewrite-clj.zip :as z]))
(set! *warn-on-reflection* true)
(def ^:private config-path ".clj-kondo/config/modules/config.edn")
(def ^:private generated-keys
"The keys this tool owns and rewrites. Order matters only for where appended keys land."
[:api :uses :model-exports :model-imports])
;;;; ---------------------------------------------------------------------------
;;;; Ordering — must match `metabase.core.modules-test`
;;;; ---------------------------------------------------------------------------
(defn- enterprise? [module-name]
(str/starts-with? (str module-name) "enterprise/"))
(defn- sort-module-names
"Sort module symbols with `enterprise/` modules last. Mirrors the test's `sort-module-names`."
[module-names]
(sort-by (juxt #(if (enterprise? %) 1 0) str) module-names))
(defn- sort-for-key
"Return the sorted element vector for a generated key. `:uses` sorts modules enterprise-last; everything
else (namespace symbols for `:api`, `:model/X` keywords for the model keys) sorts naturally."
[k elements]
(vec (if (= k :uses)
(sort-module-names elements)
(sort elements))))
;;;; ---------------------------------------------------------------------------
;;;; Set nodes
;;;; ---------------------------------------------------------------------------
(defn- set-elements
"Ordered vector of element sexprs in a `:set` node, in file order. (`z/sexpr` would return an unordered
set, useless for the sortedness comparison.)"
[set-node]
(into [] (comp (filter #(= :token (n/tag %))) (map n/sexpr))
(n/children set-node)))
(defn- build-set-node
"rewrite-clj `:set` node for `sorted-elements`. Small sets (<=3) stay inline; larger ones go one element
per line, continuation lines indented `indent` spaces to align under the first element. Matches the
hand-formatting convention used throughout config.edn (and by [[dev.model-boundary-config]])."
[sorted-elements indent]
(r.parser/parse-string
(if (<= (count sorted-elements) 3)
(str "#{" (str/join " " sorted-elements) "}")
(let [pad (apply str (repeat indent \space))]
(str "#{" (str/join (str "\n" pad) sorted-elements) "}")))))
;;;; ---------------------------------------------------------------------------
;;;; Zipper navigation
;;;; ---------------------------------------------------------------------------
(defn- modules-map-zloc
"Zipper at the `:metabase/modules` map value."
[root-node]
(-> (z/of-node root-node)
(z/find-value z/next :metabase/modules)
z/right))
(defn- module-config-zloc
"Zipper at the config map for `module-sym`, or nil."
[root-node module-sym]
(some-> (z/find-value (z/down (modules-map-zloc root-node)) z/right module-sym)
z/right))
(defn- key-value-zloc
"Zipper at the value of `k` inside a map zipper, or nil."
[map-zloc k]
(some-> (z/find-value (z/down map-zloc) z/right k)
z/right))
(defn- module-syms-in-order
"Vector of module symbols in file order (skips non-symbol keys like `:ignored-namespace-patterns`)."
[root-node]
(loop [zloc (z/down (modules-map-zloc root-node)), acc []]
(if-not zloc
acc
(let [k (z/sexpr zloc)]
(recur (some-> zloc z/right z/right)
(cond-> acc (symbol? k) (conj k)))))))
;;;; ---------------------------------------------------------------------------
;;;; Editing
;;;; ---------------------------------------------------------------------------
(defn- append-indent
"Continuation indent for a freshly appended `:key #{...}` line: aligns elements under the first one."
[k]
;; " :key " + "#{" -> element starts two columns past `#`
(+ 3 (count (str k)) 1 2))
(defn- update-module-key
"Rewrite one generated `key` of one module in `root-node`, returning the new root node.
`computed` is the desired set (possibly empty). `indent` is the continuation indent for an existing set
(from its `#{` column) or nil to fall back to an appended-key indent."
[root-node module-sym k computed indent]
(let [map-zloc (module-config-zloc root-node module-sym)
val-zloc (when map-zloc (key-value-zloc map-zloc k))]
(cond
;; No such module (structural — handled/warned elsewhere).
(nil? map-zloc) root-node
;; Sentinel value (`:any`/`:bypass`) — human-owned, leave untouched.
(and val-zloc (not (set? (z/sexpr val-zloc)))) root-node
;; Existing set value.
val-zloc
(let [elements (set-elements (z/node val-zloc))
target (sort-for-key k computed)]
(cond
(empty? computed)
(if (#{:model-exports :model-imports} k)
;; Model keys default to absent; drop the now-empty key entirely.
(-> val-zloc z/remove z/remove z/root)
;; :api/:uses stay explicit; normalize to `#{}` only if not already empty.
(if (empty? elements)
root-node
(z/root (z/replace val-zloc (n/set-node [])))))
;; Already correct and sorted — leave the node (and its comments) byte-for-byte.
(= elements target) root-node
:else
(z/root (z/replace val-zloc (build-set-node target (or indent (append-indent k)))))))
;; Key missing but needed — append it to the module's config map.
(empty? computed) root-node
:else
(let [m-node (z/node map-zloc)
set-node (build-set-node (sort-for-key k computed) (append-indent k))
children (concat (n/children m-node)
[(n/newlines 1) (n/spaces 3)
(n/keyword-node k) (n/spaces 1) set-node])]
(z/root (z/replace map-zloc (n/replace-children m-node children)))))))
;;;; ---------------------------------------------------------------------------
;;;; Indents (captured from the pristine, position-tracked parse)
;;;; ---------------------------------------------------------------------------
(defn- collect-indents
"Map of `[module-sym key]` -> continuation-indent (spaces) for every existing generated set value,
read from element `#{` columns so rebuilt sets align exactly like their neighbours. Navigates the
position-tracked zipper `pos-root` directly (re-wrapping via `z/of-node` would drop positions)."
[pos-root]
(let [mods (-> pos-root (z/find-value z/next :metabase/modules) z/right)]
(loop [zloc (z/down mods), acc {}]
(if-not zloc
acc
(let [k (z/sexpr zloc)
vmap (z/right zloc)]
(recur (some-> vmap z/right)
(if (symbol? k)
(reduce
(fn [acc gk]
(let [vz (some-> (z/find-value (z/down vmap) z/right gk) z/right)]
(if (and vz (= :set (z/tag vz)))
(assoc acc [k gk] (inc (second (z/position vz))))
acc)))
acc
generated-keys)
acc)))))))
;;;; ---------------------------------------------------------------------------
;;;; Compute the desired config (single shared parse pass)
;;;; ---------------------------------------------------------------------------
(defn compute-desired
"Compute the desired value of every generated key for every module.
Returns `{module-sym {:api set, :uses set, :model-exports set, :model-imports set}}`.
The 4-arity is pure: it derives the answer purely from the four inputs (the parsed Kondo `config`, the
`dependencies` file-scan, `model-ownership`, and `model-references`), so it can be exercised in tests
with mock data and no file or REPL state. The 0-arity gathers those four inputs from [[dev.deps-graph]]
for real, running the independent file-scanning passes concurrently so a warm REPL finishes in a few
seconds."
([]
(let [config (deps-graph/kondo-config)
f-deps (future (deps-graph/dependencies))
f-own (future (deps-graph/model-ownership))
f-refs (future (deps-graph/model-references-by-module))]
(compute-desired config @f-deps @f-own @f-refs)))
([config dependencies model-ownership model-references]
(let [api+uses (deps-graph/generate-config dependencies config)
boundaries (mbc/compute-model-boundaries config model-ownership model-references)
modules (into #{} (concat (keys api+uses)
(keys (:model-exports boundaries))
(keys (:model-imports boundaries))))]
(into {}
(map (fn [module]
[module {:api (get-in api+uses [module :api] #{})
:uses (get-in api+uses [module :uses] #{})
:model-exports (get-in boundaries [:model-exports module] #{})
:model-imports (get-in boundaries [:model-imports module] #{})}]))
modules))))
;;;; ---------------------------------------------------------------------------
;;;; Structural warnings (things this tool won't auto-fix)
;;;; ---------------------------------------------------------------------------
(defn- structural-warnings
"Sequence of human-readable warnings for structural mismatches this tool won't touch."
[file-modules desired]
(let [file-set (set file-modules)
desired-set (set (keys desired))
to-add (sort (remove file-set desired-set))
;; a desired module absent from the file needs adding; but connection-pool etc. legitimately live
;; only in the file, so only flag file modules that produced *no* generated data as candidates.
sorted? (= file-modules (sort-module-names file-modules))]
(cond-> []
(seq to-add)
(conj (str "New module(s) not in config — add them by hand (they need a :team): "
(str/join ", " to-add)))
(not sorted?)
(conj "Modules are not sorted by name (enterprise/ last) — reorder them by hand."))))
;;;; ---------------------------------------------------------------------------
;;;; Entry point
;;;; ---------------------------------------------------------------------------
(defn rewrite-config
"Pure core of the tool. Given the current config.edn `original` text and the `desired` per-module
generated-key map (see [[compute-desired]]), return
{:text the rewritten config text
:warnings [human-readable strings for structural mismatches this tool won't touch]}
Idempotent: a config already matching `desired` yields `:text` byte-for-byte equal to `original`. Only
modules present in `desired` are touched, so library-sourced entries like `connection-pool` (dissoc'd
from the deps graph) are left alone."
[original desired]
(let [pos-root (z/of-string original {:track-position? true})
indents (collect-indents pos-root)
;; full `:forms` node: keeps the header/footer comment blocks that surround the top-level map.
full-root (z/root pos-root)
file-modules (module-syms-in-order full-root)
edited (reduce
(fn [root-node module-sym]
(reduce
(fn [root-node k]
(update-module-key root-node module-sym k
(get-in desired [module-sym k] #{})
(get indents [module-sym k])))
root-node
generated-keys))
full-root
(filter desired file-modules))]
{:text (n/string edited)
:warnings (structural-warnings file-modules desired)}))
(defn update-config!
"Rewrite `.clj-kondo/config/modules/config.edn` so the generated keys are correct and sorted.
Idempotent: a clean config is left byte-for-byte unchanged. Returns `:updated` or `:unchanged`."
[]
(let [original (slurp config-path)
{:keys [text warnings]} (rewrite-config original (compute-desired))]
#_{:clj-kondo/ignore [:discouraged-var]}
(doseq [w warnings]
(println (str "WARNING: " w)))
(if (= text original)
(do #_{:clj-kondo/ignore [:discouraged-var]}
(println "config.edn already up to date.")
:unchanged)
(do (spit config-path text)
#_{:clj-kondo/ignore [:discouraged-var]}
(println "Updated" config-path)
:updated))))
(defn fix-config!
"Cold-JVM entry point for `clojure -X:dev dev.modules-config/fix-config!`. Takes (and ignores) the
`-X` exec-args map."
[_]
(update-config!)
(shutdown-agents))

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

@@ -0,0 +1,147 @@
(ns dev.modules-config-test
(:require
[clojure.edn :as edn]
[clojure.java.io :as io]
[clojure.string :as str]
[clojure.test :refer :all]
[dev.modules-config :as mc]
[rewrite-clj.node :as n]
[rewrite-clj.parser :as r.parser]))
(defn- resource [path]
(slurp (io/resource (str "dev/modules_config_test/" path))))
(defn- rewrite
"Rewrite `input` text against `desired`, returning just the text."
[input desired]
(:text (mc/rewrite-config input desired)))
;;;; ---------------------------------------------------------------------------
;;;; End-to-end golden test: full config + mock inputs -> expected config
;;;; ---------------------------------------------------------------------------
(deftest ^:parallel full-config-rewrite-test
(testing "a full config.edn rewritten against mock deps-graph inputs matches the expected resource"
(let [{:keys [config dependencies model-ownership model-references]}
(edn/read-string (resource "mocks.edn"))
desired (mc/compute-desired config dependencies model-ownership model-references)
{:keys [text warnings]} (mc/rewrite-config (resource "input-config.edn") desired)]
(is (= (resource "expected-config.edn") text))
(is (= [] warnings)
"the scenario is structurally sound (no new/unsorted modules)")
(testing "and rewriting is idempotent — re-running on the output is a no-op"
(is (= text (rewrite text desired)))))))
;;;; ---------------------------------------------------------------------------
;;;; compute-desired is pure: value in, value out
;;;; ---------------------------------------------------------------------------
(deftest ^:parallel compute-desired-test
(let [{:keys [config dependencies model-ownership model-references]}
(edn/read-string (resource "mocks.edn"))
desired (mc/compute-desired config dependencies model-ownership model-references)]
(testing ":api = a module's namespaces used by *other* modules; :uses = modules it depends on"
(is (= #{'metabase.beta.core} (get-in desired ['beta :api])))
(is (= #{'beta 'gamma} (get-in desired ['alpha :uses])))
(is (= #{} (get-in desired ['enterprise/zeta :api]))
"zeta is used by nobody"))
(testing ":model-exports = owned models referenced elsewhere; a model only used at home is not exported"
(is (= #{:model/Alpha} (get-in desired ['alpha :model-exports])))
(is (= #{} (get-in desired ['enterprise/zeta :model-exports]))
"zeta owns :model/Zeta but only references it itself"))
(testing ":model-imports = referenced models owned by other modules"
(is (= #{:model/Beta :model/Gamma} (get-in desired ['alpha :model-imports]))))
(testing "a :bypass module drives no imports and its references don't force exports"
(is (= #{} (get-in desired ['gamma :model-imports]))))))
;;;; ---------------------------------------------------------------------------
;;;; Sorting (must match metabase.core.modules-test)
;;;; ---------------------------------------------------------------------------
(deftest ^:parallel sort-module-names-test
(testing "enterprise/ modules sort last, others alphabetically"
(is (= '[alpha beta enterprise/aaa enterprise/zzz]
(#'mc/sort-module-names '[enterprise/zzz beta enterprise/aaa alpha])))))
(deftest ^:parallel sort-for-key-test
(testing ":uses sorts modules enterprise-last; other keys sort naturally"
(is (= '[alpha enterprise/zeta] (#'mc/sort-for-key :uses '[enterprise/zeta alpha])))
(is (= '[a.b a.c a.d] (#'mc/sort-for-key :api '[a.d a.b a.c])))
(is (= [:model/A :model/B] (#'mc/sort-for-key :model-imports [:model/B :model/A])))))
;;;; ---------------------------------------------------------------------------
;;;; Set nodes
;;;; ---------------------------------------------------------------------------
(deftest ^:parallel set-elements-test
(testing "elements come back in file order (not the unordered order z/sexpr would give)"
(is (= '[gamma alpha beta]
(#'mc/set-elements (r.parser/parse-string "#{gamma alpha beta}"))))
(is (= '[a b]
(#'mc/set-elements (r.parser/parse-string "#{a ; trailing\n b}")))
"comments and whitespace are skipped")))
(deftest ^:parallel build-set-node-test
(testing "<=3 elements stay inline"
(is (= "#{a b c}" (n/string (#'mc/build-set-node '[a b c] 10)))))
(testing ">3 elements go one per line, continuation lines indented to `indent`"
(is (= "#{a\n b\n c\n d}"
(n/string (#'mc/build-set-node '[a b c d] 4)))))
(testing "empty set"
(is (= "#{}" (n/string (#'mc/build-set-node [] 4))))))
;;;; ---------------------------------------------------------------------------
;;;; rewrite-config branches (via tiny hand-written configs)
;;;; ---------------------------------------------------------------------------
(defn- parse-modules [text]
(:metabase/modules (edn/read-string text)))
(deftest ^:parallel sentinel-values-are-preserved-test
(testing ":any and :bypass survive even when a concrete value is computed"
(let [input "{:metabase/modules {m {:api :any :uses :any :model-imports :bypass}}}"]
(is (= input (rewrite input '{m {:api #{metabase.m.core}
:uses #{other}
:model-imports #{:model/X}}}))))))
(deftest ^:parallel missing-key-is-appended-test
(let [input "{:metabase/modules {m {:team \"T\"}}}"
output (rewrite input '{m {:api #{metabase.m.core}}})]
(is (= #{'metabase.m.core} (get-in (parse-modules output) ['m :api])))))
(deftest ^:parallel unsorted-set-is-resorted-test
(let [input "{:metabase/modules {m {:uses #{gamma beta alpha}}}}"]
(is (str/includes? (rewrite input '{m {:uses #{alpha beta gamma}}})
"#{alpha beta gamma}"))))
(deftest ^:parallel empty-computed-values-test
(testing "an emptied model key is removed entirely"
(let [input "{:metabase/modules {m {:model-exports #{:model/X}}}}"
output (rewrite input '{m {:model-exports #{}}})]
(is (not (contains? (get (parse-modules output) 'm) :model-exports)))))
(testing "an emptied :api is normalized to #{} rather than removed"
(let [input "{:metabase/modules {m {:api #{metabase.m.core}}}}"
output (rewrite input '{m {:api #{}}})]
(is (= #{} (get-in (parse-modules output) ['m :api]))))))
(deftest ^:parallel comment-preservation-test
(testing "a comment inside a set that does NOT change is preserved"
(let [input "{:metabase/modules {m {:uses #{alpha ; keep me\n beta}}}}"]
(is (str/includes? (rewrite input '{m {:uses #{alpha beta}}}) "; keep me"))))
(testing "a comment inside a set that IS resorted is dropped"
(let [input "{:metabase/modules {m {:uses #{beta ; drop me\n alpha}}}}"]
(is (not (str/includes? (rewrite input '{m {:uses #{alpha beta}}}) "; drop me"))))))
;;;; ---------------------------------------------------------------------------
;;;; Structural warnings (things the tool won't auto-fix, only reports)
;;;; ---------------------------------------------------------------------------
(deftest ^:parallel structural-warnings-test
(testing "a desired module absent from the file is reported (needs a human-assigned :team)"
(let [{:keys [warnings]} (mc/rewrite-config "{:metabase/modules {a {:uses #{}}}}"
'{a {:uses #{}} b {:uses #{}}})]
(is (some #(str/includes? % "b") warnings))))
(testing "out-of-order modules are reported"
(let [{:keys [warnings]} (mc/rewrite-config "{:metabase/modules {beta {:uses #{}} alpha {:uses #{}}}}"
'{alpha {:uses #{}} beta {:uses #{}}})]
(is (some #(str/includes? % "sorted") warnings)))))

View File

@@ -9,6 +9,8 @@ summary: Connect MCP-compatible AI clients to Metabase to search, explore, and q
Metabase includes an [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that lets AI clients connect directly to your Metabase, all scoped to the connecting person's permissions.
> To learn how to use the Metabase MCP server and CLI for AI data exploration, [register for our free MCP and CLI workshop](https://www.metabase.com/events/workshop-metabase-mcp-cli).
## Connect a client to your Metabase MCP server's URL
Your Metabase's MCP server is served from the `/api/metabase-mcp` endpoint.

View File

@@ -7,6 +7,8 @@ summary: Overview of all the ways you can use AI with Metabase.
AI in Metabase is optional. You can use Metabase without AI at all. But if you do want to use AI to interact with Metabase, we have you covered.
> To learn how to use the Metabase MCP server and CLI for AI data exploration, [register for our free MCP and CLI workshop](https://www.metabase.com/events/workshop-metabase-mcp-cli).
Here are the different ways to use AI with Metabase:
- [Metabot](#metabot)
@@ -24,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:

2055
docs/api.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -171,6 +171,8 @@ config:
humanization-strategy: simple
index-update-thread-count: 2
install-analytics-database: true
jdbc-data-warehouse-connection-pool-checkout-timeout-ms: 0
jdbc-data-warehouse-connection-pool-max-pending-checkouts: 0
jdbc-data-warehouse-max-connection-pool-size: 15
jdbc-network-timeout-ms: 1800000
jwt-attribute-email: email
@@ -220,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
@@ -240,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
@@ -320,7 +327,7 @@ config:
thread-interrupt-escalation-timeout-ms: 0
transform-run-job-sql-concurrency: 3
transform-timeout: 240
transforms-enabled: false
transforms-enabled: null
unaggregated-query-row-limit: null
uploads-settings: null
usage-metadata-enabled: false

View File

@@ -361,7 +361,7 @@ Whether to (asynchronously) sync newly created Databases during config-from-file
- [Exported as](../installation-and-operation/serialization.md): `csp-img-allowed-hosts`.
- [Configuration file name](./config-file.md): `csp-img-allowed-hosts`
Comma-separated list of hosts that images may load from (e.g. in dashboard text, entity descriptions, and custom visualizations) when `csp-img-enabled` is on. Empty by default, which restricts images to this Metabase instance and the map tile server used by map visualizations (`map-tile-server-url`, always allowed automatically).
Comma-separated list of hosts that images may load from (e.g. in dashboard text, entity descriptions, and custom visualizations) when `csp-img-enabled` is on. Empty by default, which restricts images to this Metabase instance and the map tile server used by map visualizations.
### `MB_CSP_IMG_ENABLED`
@@ -909,6 +909,38 @@ Whether or not we should install the Metabase analytics database on startup. Def
Setting this environment variable to false will prevent installing the analytics database, which is handy in a migration use-case where it conflicts with the incoming database.
### `MB_JDBC_DATA_WAREHOUSE_CONNECTION_POOL_CHECKOUT_TIMEOUT_MS`
- Type: integer
- Default: `0`
Number of milliseconds a query will wait for a free data-warehouse connection once the c3p0 pool has hit
jdbc-data-warehouse-max-connection-pool-size before giving up. Maps to c3p0's `checkoutTimeout`. `0` waits
indefinitely (the old, unbounded behavior); a positive value fails fast, which the query processor surfaces to the
frontend as an HTTP 503 (Service Unavailable) rather than letting the request queue grow without limit.
When every data-warehouse connection is in use, additional queries wait for one to free up. This is the
maximum time (in milliseconds) a query will wait before failing with a "service unavailable" (HTTP 503) error
instead of queueing indefinitely. Raise it if you routinely run more concurrent queries than
MB_JDBC_DATA_WAREHOUSE_MAX_CONNECTION_POOL_SIZE and would rather have them wait; set it to `0` to wait forever.
### `MB_JDBC_DATA_WAREHOUSE_CONNECTION_POOL_MAX_PENDING_CHECKOUTS`
- Type: integer
- Default: `0`
Maximum number of queries allowed to be waiting for a free data-warehouse connection at once, once the c3p0 pool has
hit jdbc-data-warehouse-max-connection-pool-size. When this many queries are already queued waiting for a
connection, further queries fail fast instead of joining the queue, which the query processor surfaces to the
frontend as an HTTP 503 (Service Unavailable). `0` (the default) lets the queue grow without bound (the old
behavior). Complements jdbc-data-warehouse-connection-pool-checkout-timeout-ms, which bounds how long each query
waits; this bounds how many can wait at the same time.
When every data-warehouse connection is in use, additional queries wait for one to free up. This is the
maximum number of queries that may be waiting at the same time before further queries fail immediately with a
"service unavailable" (HTTP 503) error instead of joining the queue. Raise it to tolerate deeper bursts; set it to
`0` to allow an unbounded queue.
### `MB_JDBC_DATA_WAREHOUSE_MAX_CONNECTION_POOL_SIZE`
- Type: integer
@@ -1269,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`
@@ -1362,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
@@ -1549,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
@@ -2295,10 +2363,14 @@ Controls the timeout for transform runs, including the queries they execute. Thi
### `MB_TRANSFORMS_ENABLED`
- Type: boolean
- Default: `false`
- Default: `null`
- [Exported as](../installation-and-operation/serialization.md): `transforms-enabled`.
- [Configuration file name](./config-file.md): `transforms-enabled`
Enable transforms for instances that have not explicitly purchased the transform add-on.
Whether transforms are enabled.
When enabled, data analysts and admins can write, schedule and run transforms.
Disabling this feature will hide all transform features, prevent transform editing or creation, and prevent any new runs.
### `MB_UNAGGREGATED_QUERY_ROW_LIMIT`

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

@@ -19,6 +19,7 @@ To manage upload settings, admins can hit cmd/ctrl + K and search for "Settings
- [PostgreSQL](../databases/connections/postgresql.md)
- [MySQL](../databases/connections/mysql.md)
- [Snowflake](../databases/connections/snowflake.md)
- [Redshift](../databases/connections/redshift.md)
- [ClickHouse](../databases/connections/clickhouse.md) (only supported on ClickHouse Cloud)

View File

@@ -4,6 +4,10 @@ title: API changelog
# Breaking changes to the API interface
## Metabase 0.64.0
- Self-hosted environments must now explicitly enable transforms before beginning to use them via the API. Admins can enable transforms in Data Studio or by setting the MB_TRANSFORMS_ENABLED environment variable to true.
## Metabase 0.61.0
- `POST /api/metabot/describe/card` and `POST /api/metabot/describe/dashboard/:id` have been removed. These endpoints
@@ -23,13 +27,16 @@ title: API changelog
between group ids and collection id indicate that the group provides no permissions for the collection. For
example, what was returned in versions before 0.56.13:
```json
{"revision": 2, "groups": {"1": {"root": "write", "1": "read", "2": "none"}}}
{
"revision": 2,
"groups": { "1": { "root": "write", "1": "read", "2": "none" } }
}
```
becomes:
```json
{"revision": 2, "groups": {"1": {"root": "write", "1": "read"}}}
{ "revision": 2, "groups": { "1": { "root": "write", "1": "read" } } }
```
in versions 0.56.13 and up.
in versions 0.56.13 and up.
## Metabase 0.55.0

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

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