Compare commits
230 Commits
backport-6
...
python-sec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ae1585c7e | ||
|
|
b0b11dca91 | ||
|
|
8984233763 | ||
|
|
8ab2269f04 | ||
|
|
4385d33640 | ||
|
|
64588d02fb | ||
|
|
20b1d51e6b | ||
|
|
838d5a7a50 | ||
|
|
5fb83b02e7 | ||
|
|
31b35fb452 | ||
|
|
f82bdbdd52 | ||
|
|
2819934fdc | ||
|
|
ce264b2c85 | ||
|
|
07f62f33c3 | ||
|
|
adf574b57b | ||
|
|
43d61f4514 | ||
|
|
12ce1e67e7 | ||
|
|
89abda4d66 | ||
|
|
911fe173f2 | ||
|
|
8705016252 | ||
|
|
fc7d1ec13b | ||
|
|
6108e0182e | ||
|
|
479d7ff67b | ||
|
|
3e6612f075 | ||
|
|
ce8a99bf08 | ||
|
|
ed3aa72e65 | ||
|
|
4081105624 | ||
|
|
37d96787a1 | ||
|
|
e823ccc4d7 | ||
|
|
4674a3610f | ||
|
|
1f0d8d6b34 | ||
|
|
5616e61f7a | ||
|
|
3aa548b966 | ||
|
|
cb49c63e0c | ||
|
|
99661b47e3 | ||
|
|
98daa6fbf8 | ||
|
|
30e4219082 | ||
|
|
eed6ef7980 | ||
|
|
b21e9dcba1 | ||
|
|
8771f8eb73 | ||
|
|
e5a9978cac | ||
|
|
039504f1e9 | ||
|
|
09e0cf1718 | ||
|
|
ba4c6a87c3 | ||
|
|
1921483a0d | ||
|
|
0f97c3e87b | ||
|
|
62cdfb57c2 | ||
|
|
ba9e327dd0 | ||
|
|
8706236c94 | ||
|
|
5598559520 | ||
|
|
8d47578032 | ||
|
|
7cc5a515f1 | ||
|
|
bf028c04dd | ||
|
|
b2d652c6ab | ||
|
|
e29d97eb9e | ||
|
|
83215dcacd | ||
|
|
f0a65273fb | ||
|
|
0d59548a1e | ||
|
|
16a8399a5c | ||
|
|
45940c8ff1 | ||
|
|
88474b9d2a | ||
|
|
bb8b550a21 | ||
|
|
373b755b50 | ||
|
|
03a8afdba8 | ||
|
|
aaa84da26f | ||
|
|
611ef3690b | ||
|
|
559301b57b | ||
|
|
803f470a7c | ||
|
|
cb41874c74 | ||
|
|
1a8a17229a | ||
|
|
972c96b58d | ||
|
|
4c9497c75c | ||
|
|
4e5bb2daa8 | ||
|
|
9cd10f877b | ||
|
|
9a5a4e2587 | ||
|
|
7b3ed42a61 | ||
|
|
bf26412ac5 | ||
|
|
12835e1889 | ||
|
|
d65b0f5661 | ||
|
|
a77339d049 | ||
|
|
fcd561b654 | ||
|
|
d3a8d67430 | ||
|
|
bd6c7a3ef6 | ||
|
|
973bddf906 | ||
|
|
ace59d6c1c | ||
|
|
5246357b5c | ||
|
|
2ca895407f | ||
|
|
85485ed925 | ||
|
|
8d3584593a | ||
|
|
6547e398ee | ||
|
|
a6b0d2c7d6 | ||
|
|
4d04a9743c | ||
|
|
7712c474dd | ||
|
|
dbedcbfbd4 | ||
|
|
25eebabf11 | ||
|
|
30daa26a7b | ||
|
|
ebea514c84 | ||
|
|
622629ef6c | ||
|
|
d60573c199 | ||
|
|
71e305b6ab | ||
|
|
e7211002cf | ||
|
|
cf56c33cdd | ||
|
|
8eea1fc50b | ||
|
|
a420372ba0 | ||
|
|
0b7aa11b15 | ||
|
|
bc0d27253e | ||
|
|
e3791b88e8 | ||
|
|
6c25c78740 | ||
|
|
b3cfb8d510 | ||
|
|
2765c8d027 | ||
|
|
dad63b3da3 | ||
|
|
8ef62d8612 | ||
|
|
e2e10c2867 | ||
|
|
ee29a82916 | ||
|
|
ffdd62caaa | ||
|
|
6e6bca18d0 | ||
|
|
5a6a2d84e2 | ||
|
|
669dc959ff | ||
|
|
3554cc36fb | ||
|
|
5340d25eea | ||
|
|
7a39244afe | ||
|
|
f9dce94f95 | ||
|
|
2d7ed54076 | ||
|
|
f4d3f69010 | ||
|
|
88c9058a51 | ||
|
|
bd2df5059b | ||
|
|
4a12356ec8 | ||
|
|
8e13d485ec | ||
|
|
4d849db7f8 | ||
|
|
5a024bd2ff | ||
|
|
fe4a72d76f | ||
|
|
31e79dc02d | ||
|
|
64dfbf0bb3 | ||
|
|
9a6f10f9dc | ||
|
|
1ad3f2b114 | ||
|
|
98424e1a61 | ||
|
|
4d47327087 | ||
|
|
29993aa6db | ||
|
|
733e1c6f3e | ||
|
|
97f82c0792 | ||
|
|
6188ef843f | ||
|
|
544a0c2ba8 | ||
|
|
836e12b526 | ||
|
|
ba83271710 | ||
|
|
6b855cbe81 | ||
|
|
b8673198cd | ||
|
|
87f0514519 | ||
|
|
dea12ca75c | ||
|
|
71863190b1 | ||
|
|
5a4f82063e | ||
|
|
9197dc9057 | ||
|
|
0b9e1c0be4 | ||
|
|
46d217cf64 | ||
|
|
f9ecaf7f92 | ||
|
|
ae56aaf2b2 | ||
|
|
ab9a3e191a | ||
|
|
9f42bcf323 | ||
|
|
130888462d | ||
|
|
32a869b1e6 | ||
|
|
f7f8697947 | ||
|
|
960d036afd | ||
|
|
60e1b995d3 | ||
|
|
15195d006a | ||
|
|
5bd7fa6247 | ||
|
|
6eff4789f0 | ||
|
|
fe6bc8a408 | ||
|
|
117020fc2e | ||
|
|
b9bba24cfd | ||
|
|
a817ed88a6 | ||
|
|
0a3932e9cf | ||
|
|
44dc41a7fa | ||
|
|
f0ef171d25 | ||
|
|
5373a65352 | ||
|
|
a169d4ab75 | ||
|
|
31a0379d5d | ||
|
|
950f453535 | ||
|
|
7a20b77fbd | ||
|
|
7d1f271280 | ||
|
|
a9b9b7bb59 | ||
|
|
368ed03818 | ||
|
|
ffbddbbc6f | ||
|
|
c6a0c72b01 | ||
|
|
22725a83b9 | ||
|
|
a9e1b0edf7 | ||
|
|
6912a50e85 | ||
|
|
5f56057bfe | ||
|
|
e5bd3a34eb | ||
|
|
6097aa447b | ||
|
|
ff2b145e37 | ||
|
|
21f2c65b15 | ||
|
|
1c90ab2c31 | ||
|
|
82b65c8496 | ||
|
|
da4e98d389 | ||
|
|
4701e5f8dc | ||
|
|
18f5a74df7 | ||
|
|
339d2c91b9 | ||
|
|
51e50d157c | ||
|
|
c16d08579d | ||
|
|
07c824b6fb | ||
|
|
6aa06eaa23 | ||
|
|
7876a74202 | ||
|
|
c09cdbf49c | ||
|
|
23825aec8e | ||
|
|
eca47dd2dc | ||
|
|
8a7b431057 | ||
|
|
384f6fb9b7 | ||
|
|
b7fa7d73d2 | ||
|
|
9d523b4604 | ||
|
|
951be524ec | ||
|
|
c518575acc | ||
|
|
206ba7ee99 | ||
|
|
e78a7c57e6 | ||
|
|
892974e74e | ||
|
|
1bec6455d3 | ||
|
|
0149067bd7 | ||
|
|
54a2825522 | ||
|
|
ca6033b698 | ||
|
|
f6be96587f | ||
|
|
1bc18a3626 | ||
|
|
837a454620 | ||
|
|
3127b4e8c3 | ||
|
|
56879decb0 | ||
|
|
d81c75451c | ||
|
|
b48d367c21 | ||
|
|
5c6e3e71fe | ||
|
|
5e05b04e53 | ||
|
|
5a39e1adf8 | ||
|
|
ff5e3b6973 | ||
|
|
486296ff36 | ||
|
|
7f6472209d |
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
18
.clj-kondo/src/hooks/metabase/mq/impl.clj
Normal 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}))
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
2
.github/actions/prepare-backend/action.yml
vendored
@@ -4,7 +4,7 @@ description: |
|
||||
inputs:
|
||||
java-version:
|
||||
required: true
|
||||
default: "21"
|
||||
default: "25"
|
||||
clojure-version:
|
||||
required: true
|
||||
default: "1.12.0.1488"
|
||||
|
||||
52
.github/actions/prepare-ci-conductor/action.yml
vendored
Normal 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."
|
||||
4
.github/actions/prepare-cypress/action.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/actions/prepare-node-bun/action.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
32
.github/actions/quarantine-gate/action.yml
vendored
@@ -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) ;;
|
||||
|
||||
@@ -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:
|
||||
|
||||
53
.github/actions/test-driver/action.yml
vendored
@@ -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' }}
|
||||
|
||||
14
.github/actions/upload-test-results/action.yml
vendored
@@ -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:
|
||||
|
||||
4
.github/file-paths.yaml
vendored
@@ -7,6 +7,10 @@ default: &default
|
||||
- "locales/**"
|
||||
- "bin/**/!(*.md)"
|
||||
|
||||
actionlint:
|
||||
- ".github/workflows/*.yml"
|
||||
- ".github/workflows/*.yaml"
|
||||
|
||||
shared_sources: &shared_sources
|
||||
- "src/**/*.cljc"
|
||||
- "src/**/*.cljs"
|
||||
|
||||
17
.github/scripts/csv-to-mb.js
vendored
@@ -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) {
|
||||
|
||||
3
.github/scripts/setup-python-runner.sh
vendored
@@ -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..."
|
||||
|
||||
94
.github/scripts/upload-affected-tests-stats.js
vendored
@@ -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
@@ -93,7 +93,8 @@
|
||||
"deniskaber",
|
||||
"andsalves",
|
||||
"kevinkelleher12",
|
||||
"AndreyChernykh"
|
||||
"AndreyChernykh",
|
||||
"bowbahdoe"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
42
.github/workflows/actionlint.yml
vendored
Normal 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 }}
|
||||
12
.github/workflows/app-db.yml
vendored
@@ -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:
|
||||
|
||||
29
.github/workflows/backend.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/bundle-size-stats.yml
vendored
@@ -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
@@ -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 }}
|
||||
152
.github/workflows/drivers.yml
vendored
@@ -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 }}",
|
||||
|
||||
2
.github/workflows/e2e-matrix-builder.yml
vendored
@@ -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,
|
||||
|
||||
19
.github/workflows/e2e-test.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
19
.github/workflows/frontend.yml
vendored
@@ -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 }}
|
||||
|
||||
118
.github/workflows/linear-release-sync.yml
vendored
@@ -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.
|
||||
@@ -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
|
||||
|
||||
2
.github/workflows/pre-release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
8
.github/workflows/release-tests.yml
vendored
@@ -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
|
||||
|
||||
47
.github/workflows/release.yml
vendored
@@ -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]
|
||||
|
||||
83
.github/workflows/semantic-search.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/snowplow.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/workflows/snyk.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/tag-for-release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
4
.github/workflows/uberjar.yml
vendored
@@ -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
@@ -204,3 +204,4 @@ __pycache__/
|
||||
|
||||
# dependency-cruiser output
|
||||
dependency-graph.json
|
||||
.playwright-cli/
|
||||
|
||||
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 28 KiB |
19
CLAUDE.md
@@ -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.
|
||||
@@ -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
@@ -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"]]
|
||||
|
||||
@@ -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`."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"]))))))
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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=="],
|
||||
|
||||
36
deps.edn
@@ -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.
|
||||
|
||||
52
dev/email-preview/README.md
Normal 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.
|
||||
571
dev/email-preview/index.html
Normal 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>
|
||||
407
dev/email-preview/sample-contexts.js
Normal 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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
60
dev/email-preview/serve.ts
Normal 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" });
|
||||
}
|
||||
@@ -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
|
||||
|
||||
44
dev/resources/dev/modules_config_test/expected-config.edn
Normal 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}}}}
|
||||
42
dev/resources/dev/modules_config_test/input-config.edn
Normal 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}}}}
|
||||
36
dev/resources/dev/modules_config_test/mocks.edn
Normal 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}}}
|
||||
@@ -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]))})))
|
||||
|
||||
308
dev/src/dev/modules_config.clj
Normal 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
@@ -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
@@ -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
|
||||
147
dev/test/dev/modules_config_test.clj
Normal 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)))))
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||