Compare commits
113 Commits
exploratio
...
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 |
@@ -179,6 +179,7 @@
|
||||
clojure.core.async/take!
|
||||
clojure.core.async/to-chan!
|
||||
clojure.core.async/to-chan!!
|
||||
metabase.ai-tracing.core/record!
|
||||
metabase.api.macros.defendpoint.tools-manifest/assert-optional-fields-nullable!
|
||||
metabase.channel.core/send!
|
||||
metabase.channel.render.pdf/draw-too-large!
|
||||
@@ -624,6 +625,9 @@
|
||||
metabase.model-persistence.api-test/with-persistence-setup! hooks.common/with-one-top-level-binding
|
||||
metabase.model-persistence.api-test/with-setup! hooks.common/with-one-top-level-binding
|
||||
metabase.model-persistence.test-util/with-persistence-enabled! hooks.metabase.model-persistence.test-util/with-persistence-enabled!
|
||||
metabase.mq.core/with-queue hooks.common/let-second
|
||||
metabase.mq.test-util/with-test-mq hooks.common/with-one-binding
|
||||
metabase.mq.core/def-listener! hooks.metabase.mq.impl/def-listener
|
||||
metabase.models.interface/define-batched-hydration-method hooks.metabase.models.interface/define-hydration-method
|
||||
metabase.models.interface/define-simple-hydration-method hooks.metabase.models.interface/define-hydration-method
|
||||
metabase.permissions.core/allow-changing-all-users-group-members hooks.common/do*
|
||||
|
||||
@@ -159,7 +159,8 @@
|
||||
{:team "Metabot"
|
||||
:api #{metabase.agent-api.api
|
||||
metabase.agent-api.init}
|
||||
:uses #{api
|
||||
:uses #{ai-tracing
|
||||
api
|
||||
auth-identity
|
||||
channel
|
||||
collections
|
||||
@@ -194,6 +195,15 @@
|
||||
models
|
||||
util}}
|
||||
|
||||
ai-tracing
|
||||
{:team "Metabot"
|
||||
:api #{metabase.ai-tracing.api
|
||||
metabase.ai-tracing.core
|
||||
metabase.ai-tracing.init}
|
||||
:uses #{api
|
||||
settings
|
||||
util}}
|
||||
|
||||
analytics
|
||||
{:team "UX West"
|
||||
:api #{metabase.analytics.api
|
||||
@@ -1125,6 +1135,7 @@
|
||||
metabase.lib.metadata
|
||||
metabase.lib.metadata.protocols
|
||||
metabase.lib.options
|
||||
metabase.lib.pivot
|
||||
metabase.lib.schema
|
||||
metabase.lib.schema.actions
|
||||
metabase.lib.schema.aggregation
|
||||
@@ -1253,6 +1264,7 @@
|
||||
:model-exports #{:model/McpSessionLog
|
||||
:model/McpToolCallLog}
|
||||
:uses #{agent-api
|
||||
ai-tracing
|
||||
api
|
||||
app-db
|
||||
config
|
||||
@@ -1298,6 +1310,7 @@
|
||||
slackbot}
|
||||
:uses #{activity-feed
|
||||
agent-lib
|
||||
ai-tracing
|
||||
analytics
|
||||
analytics-interface
|
||||
api
|
||||
@@ -1435,6 +1448,17 @@
|
||||
:model/Table
|
||||
:model/User}}
|
||||
|
||||
mq
|
||||
{:team "UX West"
|
||||
:api #{metabase.mq.init}
|
||||
:uses #{analytics-interface
|
||||
app-db
|
||||
classloader
|
||||
settings
|
||||
startup
|
||||
task
|
||||
util}}
|
||||
|
||||
native-query-snippets
|
||||
{:team "Graphy"
|
||||
:api #{metabase.native-query-snippets.api
|
||||
@@ -1878,6 +1902,7 @@
|
||||
cache
|
||||
config
|
||||
dashboards
|
||||
database-routing
|
||||
driver
|
||||
events
|
||||
formatter
|
||||
@@ -1971,12 +1996,12 @@
|
||||
{:team "UX West"
|
||||
:api #{metabase.sample-data.core
|
||||
metabase.sample-data.init}
|
||||
:uses #{plugins
|
||||
:uses #{driver
|
||||
plugins
|
||||
settings
|
||||
sync
|
||||
util}
|
||||
:model-imports #{:model/Database
|
||||
:model/Table}}
|
||||
:model-imports #{:model/DataPermissions :model/Database :model/Table}}
|
||||
|
||||
search
|
||||
{:team "UX West"
|
||||
@@ -2331,6 +2356,7 @@
|
||||
permissions
|
||||
premium-features
|
||||
search
|
||||
session
|
||||
util}
|
||||
:model-imports #{:model/AiUsageLog
|
||||
:model/Card
|
||||
@@ -3106,7 +3132,6 @@
|
||||
{:team "UX West"
|
||||
:api #{metabase-enterprise.database-routing.api}
|
||||
:uses #{api
|
||||
config
|
||||
database-routing
|
||||
driver
|
||||
events
|
||||
@@ -3539,6 +3564,7 @@
|
||||
config
|
||||
connection-pool
|
||||
events
|
||||
lib-be
|
||||
llm
|
||||
metabot
|
||||
models
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
|
||||
17
.github/actions/quarantine-gate/action.yml
vendored
@@ -22,21 +22,8 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Detect bun
|
||||
id: bun
|
||||
shell: bash
|
||||
run: |
|
||||
if command -v bun >/dev/null; then
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Install bun
|
||||
if: steps.bun.outputs.present != 'true'
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version-file: "package.json"
|
||||
token: ${{ github.token }}
|
||||
- name: Prepare CI Conductor
|
||||
uses: ./.github/actions/prepare-ci-conductor
|
||||
- name: Check failures against the quarantine list
|
||||
shell: bash
|
||||
env:
|
||||
|
||||
@@ -15,21 +15,8 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Detect bun
|
||||
id: bun
|
||||
shell: bash
|
||||
run: |
|
||||
if command -v bun >/dev/null; then
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Install bun
|
||||
if: steps.bun.outputs.present != 'true'
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version-file: 'package.json'
|
||||
token: ${{ github.token }}
|
||||
- name: Prepare CI Conductor
|
||||
uses: ./.github/actions/prepare-ci-conductor
|
||||
- name: Report failed tests to ci-conductor
|
||||
shell: bash
|
||||
env:
|
||||
|
||||
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"
|
||||
|
||||
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..."
|
||||
|
||||
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 }}
|
||||
6
.github/workflows/backend.yml
vendored
@@ -121,7 +121,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
java-version: [21]
|
||||
java-version: [25]
|
||||
job:
|
||||
- name: Enterprise Tests
|
||||
edition: ee
|
||||
@@ -221,7 +221,7 @@ jobs:
|
||||
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always() && (steps.run-java-tests.conclusion != 'skipped' || steps.run-java-tests-21.conclusion != 'skipped')
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: be-tests-java-${{ matrix.java-version }}-${{ matrix.job.edition }}
|
||||
@@ -259,7 +259,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
java-version: [21]
|
||||
java-version: [25]
|
||||
name: "Check Java ${{ matrix.java-version }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
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 }}
|
||||
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,
|
||||
|
||||
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
|
||||
|
||||
67
.github/workflows/semantic-search.yml
vendored
@@ -301,12 +301,79 @@ jobs:
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
# pgvector-on-the-app-db mode: the app db itself is a pgvector-capable Postgres and
|
||||
# MB_PGVECTOR_DB_URL is deliberately unset. The dedicated-harness tests gate themselves off; the
|
||||
# appdb-pgvector-mode round trip (and the URL-independent unit tests) run for real.
|
||||
semantic-search-tests-appdb-mode:
|
||||
if: ${{ !inputs.skip }}
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version:
|
||||
- name: pgvector pg17 as app db, no dedicated pgvector
|
||||
junit-name: semantic-search-tests-appdb-mode-pg17
|
||||
appdb-image: pgvector/pgvector:pg17
|
||||
job:
|
||||
- name: Semantic Search Tests
|
||||
build-static-viz: false
|
||||
test-args: >-
|
||||
:only '["enterprise/backend/test/metabase_enterprise/semantic_search" "enterprise/backend/test/metabase_enterprise/entity_retrieval" "enterprise/backend/test/metabase_enterprise/metabot/tools/semantic_search_test.clj"]'
|
||||
exclude-tag: ':mb/driver-tests'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: ${{ matrix.version.appdb-image }}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
env:
|
||||
POSTGRES_USER: mb_test
|
||||
POSTGRES_DB: mb_test
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
env:
|
||||
CI: 'true'
|
||||
MB_DB_TYPE: postgres
|
||||
MB_DB_PORT: 5432
|
||||
MB_DB_HOST: localhost
|
||||
MB_DB_DBNAME: mb_test
|
||||
MB_DB_USER: mb_test
|
||||
# opts this job into the destructive appdb-pgvector-mode round trip, which then FAILS (rather
|
||||
# than silently skipping) if the app db can't actually host pgvector
|
||||
MB_APPDB_PGVECTOR_MODE_TEST: 'true'
|
||||
name: "${{ matrix.version.name }} ${{ matrix.job.name }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Test ${{ matrix.version.name }}
|
||||
id: test-driver
|
||||
uses: ./.github/actions/test-driver
|
||||
with:
|
||||
build-static-viz: ${{ matrix.job.build-static-viz }}
|
||||
junit-name: ${{ matrix.version.junit-name }}
|
||||
test-args: >-
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
trunk-api-token: ${{ secrets.TRUNK_API_TOKEN }}
|
||||
previous-step-outcome: ${{ steps.test-driver.outputs.test-outcome }}
|
||||
run-trunk: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
semantic-search-tests-result:
|
||||
needs:
|
||||
- semantic-search-tests-h2
|
||||
- semantic-search-tests-mariadb
|
||||
- semantic-search-tests-mysql
|
||||
- semantic-search-tests-postgres
|
||||
- semantic-search-tests-appdb-mode
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
name: semantic-search-tests-result
|
||||
|
||||
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/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
|
||||
|
||||
|
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 |
|
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 |
@@ -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
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
38
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",
|
||||
@@ -147,12 +146,10 @@
|
||||
"react-redux": "^9.1.2",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-router": "3",
|
||||
"react-router-redux": "^4.0.8",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-use": "^17.5.1",
|
||||
"react-virtualized": "9.22.6",
|
||||
"redux-actions": "^2.0.1",
|
||||
"redux-auth-wrapper": "2.1.0",
|
||||
"redux-undo": "^1.1.0",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
@@ -271,7 +268,6 @@
|
||||
"@types/react-grid-layout": "^1.3.5",
|
||||
"@types/react-is": "^18.2.4",
|
||||
"@types/react-resizable": "^3.0.4",
|
||||
"@types/react-router-redux": "^4.0.53",
|
||||
"@types/react-textarea-autosize": "^4.3.6",
|
||||
"@types/react-transition-group": "^4.4.5",
|
||||
"@types/react-virtualized": "^9.22.2",
|
||||
@@ -362,6 +358,7 @@
|
||||
"open": "^10.1.0",
|
||||
"open-cli": "^8.0.0",
|
||||
"openapi-typescript": "^7.10.0",
|
||||
"otpauth": "^9.5.1",
|
||||
"oxfmt": "^0.38.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"pg": "^8.8.0",
|
||||
@@ -456,6 +453,7 @@
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-view": "^1.40.1",
|
||||
"qs": "6.15.2",
|
||||
"redux": "5.0.1",
|
||||
"webpack": "5.105.0",
|
||||
"webpack-dev-middleware": "^5.3.4",
|
||||
"webpack-dev-server": "^5.2.1",
|
||||
@@ -1235,7 +1233,7 @@
|
||||
|
||||
"@nicolo-ribaudo/eslint-scope-5-internals": ["@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1", "", { "dependencies": { "eslint-scope": "5.1.1" } }, "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
|
||||
|
||||
"@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
|
||||
|
||||
@@ -1973,8 +1971,6 @@
|
||||
|
||||
"@types/react-router": ["@types/react-router@3.0.27", "", { "dependencies": { "@types/history": "^3", "@types/react": "*" } }, "sha512-Sq4bfTZ8TuHFTHXyzA3tu/BTGm/d4B8CwtOK4qOyJNoJMkY87qMOazkBizMHNpszVR1+kYOCNLjPdh/wAD7ESw=="],
|
||||
|
||||
"@types/react-router-redux": ["@types/react-router-redux@4.0.55", "", { "dependencies": { "@types/history": "^3.2.5", "redux": "^3.6.0" } }, "sha512-2fbxIc9OTmVyOUgBnRa3gcbMJT035Scu1bmkYMEiv7IzMht24W+gGzIrlYurSz3cijY4r9d16O/0+nycZ0RXjg=="],
|
||||
|
||||
"@types/react-textarea-autosize": ["@types/react-textarea-autosize@4.3.6", "", { "dependencies": { "@types/react": "*" } }, "sha512-cTf8tCem0c8A7CERYbTuF+bRFaqYu7N7HLCa6ZhUhDx8XnUsTpGx5udMWljt87JpciUKuUkImKPEsy6kcKhrcQ=="],
|
||||
|
||||
"@types/react-transition-group": ["@types/react-transition-group@4.4.5", "", { "dependencies": { "@types/react": "*" } }, "sha512-juKD/eiSM3/xZYzjuzH6ZwpP+/lejltmiS3QEzV/vmb/Q8+HfDmxu+Baga8UEMGBqV88Nbg4l2hY/K2DkyaLLA=="],
|
||||
@@ -1985,8 +1981,6 @@
|
||||
|
||||
"@types/redux-actions": ["@types/redux-actions@2.6.5", "", {}, "sha512-RgXOigay5cNweP+xH1ru+Vaaj1xXYLpWIfSVO8cSA8Ii2xvR+HRfWYdLe1UVOA8X0kIklalGOa0DTDyld0obkg=="],
|
||||
|
||||
"@types/redux-auth-wrapper": ["@types/redux-auth-wrapper@2.0.16", "", { "dependencies": { "@types/history": "^4.7.11", "@types/react": "*", "redux": "^3.7.2" } }, "sha512-Uj3IFMqVD4e9aCiBPrw+Ix5msJvWlrjRAVa1iV529zNzEYhdtLfVq9/tM4K9yZWMRjLFqXbTam2qz2bgnklrSA=="],
|
||||
|
||||
"@types/resolve": ["@types/resolve@1.20.6", "", {}, "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ=="],
|
||||
|
||||
"@types/retry": ["@types/retry@0.12.2", "", {}, "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow=="],
|
||||
@@ -2863,8 +2857,6 @@
|
||||
|
||||
"decode-tiff": ["decode-tiff@0.2.1", "", {}, "sha512-v/7hQBv/DrOVQ+Eljg0BLMRbXZYuuw3YZ8duZuFxYpo6qUkdn7oFRkN95RZKbnh08EHNjrMXMbEUNhTLuhPvvA=="],
|
||||
|
||||
"decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
|
||||
|
||||
"dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="],
|
||||
|
||||
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||
@@ -4297,6 +4289,8 @@
|
||||
|
||||
"ospath": ["ospath@1.2.2", "", {}, "sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA=="],
|
||||
|
||||
"otpauth": ["otpauth@9.5.1", "", { "dependencies": { "@noble/hashes": "2.2.0" } }, "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ=="],
|
||||
|
||||
"outdent": ["outdent@0.8.0", "", {}, "sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A=="],
|
||||
|
||||
"outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
|
||||
@@ -4721,8 +4715,6 @@
|
||||
|
||||
"react-router": ["react-router@3.2.6", "", { "dependencies": { "create-react-class": "^15.5.1", "history": "^3.0.0", "hoist-non-react-statics": "^3.3.2", "invariant": "^2.2.1", "loose-envify": "^1.2.0", "prop-types": "^15.7.2", "react-is": "^16.13.0", "warning": "^3.0.0" } }, "sha512-nlxtQE8B22hb/JxdaslI1tfZacxFU8x8BJryXOnR2RxB4vc01zuHYAHAIgmBkdk1kzXaA25hZxK6KAH/+CXArw=="],
|
||||
|
||||
"react-router-redux": ["react-router-redux@4.0.8", "", {}, "sha1-InQDWWtRUeGCN32rg1tdRfD4BU4="],
|
||||
|
||||
"react-router-v7": ["react-router@7.18.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" } }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
@@ -4771,12 +4763,10 @@
|
||||
|
||||
"reduce-reducers": ["reduce-reducers@0.4.3", "", {}, "sha512-+CNMnI8QhgVMtAt54uQs3kUxC3Sybpa7Y63HR14uGLgI9/QR5ggHvpxwhGGe3wmx5V91YwqQIblN9k5lspAmGw=="],
|
||||
|
||||
"redux": ["redux@3.7.2", "", { "dependencies": { "lodash": "^4.2.1", "lodash-es": "^4.2.1", "loose-envify": "^1.1.0", "symbol-observable": "^1.0.3" } }, "sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A=="],
|
||||
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
|
||||
|
||||
"redux-actions": ["redux-actions@2.6.5", "", { "dependencies": { "invariant": "^2.2.4", "just-curry-it": "^3.1.0", "loose-envify": "^1.4.0", "reduce-reducers": "^0.4.3", "to-camel-case": "^1.0.0" } }, "sha512-pFhEcWFTYNk7DhQgxMGnbsB1H2glqhQJRQrtPb96kD3hWiZRzXHwwmFPswg6V2MjraXRXWNmuP9P84tvdLAJmw=="],
|
||||
|
||||
"redux-auth-wrapper": ["redux-auth-wrapper@2.1.0", "", { "dependencies": { "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4", "lodash.isempty": "^4.4.0", "prop-types": "^15.5.0", "query-string": "^5.1.1" } }, "sha512-UtU64cJk2pWcMMfgWIVoyBVG0p8ZiGJ++vqrvQ5r5ghZZOLRq+M5aTS0RRNneiB+aCCZBzI+txFSaKYVRrv8qQ=="],
|
||||
|
||||
"redux-thunk": ["redux-thunk@3.1.0", "", {}, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||
|
||||
"redux-undo": ["redux-undo@1.1.0", "", {}, "sha512-zzLFh2qeF0MTIlzDhDLm9NtkfBqCllQJ3OCuIl5RKlG/ayHw6GUdIFdMhzMS9NnrnWdBX5u//ExMOHpfudGGOg=="],
|
||||
@@ -5195,8 +5185,6 @@
|
||||
|
||||
"swc-plugin-coverage-instrument": ["swc-plugin-coverage-instrument@0.0.32", "", {}, "sha512-KsO1xNQdcVb331Rm98Op6uYf36/CYdF1kILQxOddmtCNlRLISpCUqHcnogu+QgELZvG48oie+w0fzS8uktjcEA=="],
|
||||
|
||||
"symbol-observable": ["symbol-observable@1.2.0", "", {}, "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
"synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="],
|
||||
@@ -5841,12 +5829,12 @@
|
||||
|
||||
"@redocly/openapi-core/ajv": ["@redocly/ajv@8.17.4", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-BieiCML/IgP6x99HZByJSt7fJE4ipgzO7KAFss92Bs+PEI35BhY7vGIysFXLT+YmS7nHtQjZjhOQyPPEf7xGHA=="],
|
||||
|
||||
"@redocly/respect-core/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"@redocly/respect-core/ajv": ["@redocly/ajv@8.17.4", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-BieiCML/IgP6x99HZByJSt7fJE4ipgzO7KAFss92Bs+PEI35BhY7vGIysFXLT+YmS7nHtQjZjhOQyPPEf7xGHA=="],
|
||||
|
||||
"@redocly/respect-core/colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
|
||||
|
||||
"@reduxjs/toolkit/redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
|
||||
|
||||
"@rushstack/node-core-library/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"@rushstack/node-core-library/fs-extra": ["fs-extra@11.3.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew=="],
|
||||
@@ -5983,8 +5971,6 @@
|
||||
|
||||
"@types/react-virtualized/@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
|
||||
|
||||
"@types/redux-auth-wrapper/@types/history": ["@types/history@4.7.11", "", {}, "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA=="],
|
||||
|
||||
"@types/serve-index/@types/express": ["@types/express@4.17.13", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.18", "@types/qs": "*", "@types/serve-static": "*" } }, "sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA=="],
|
||||
|
||||
"@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="],
|
||||
@@ -6299,8 +6285,6 @@
|
||||
|
||||
"diffie-hellman/bn.js": ["bn.js@4.11.9", "", {}, "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw=="],
|
||||
|
||||
"dnd-core/redux": ["redux@4.2.0", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA=="],
|
||||
|
||||
"dom-serializer/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||
|
||||
"domhandler/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||
@@ -7005,8 +6989,6 @@
|
||||
|
||||
"reduce-function-call/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"redux-auth-wrapper/query-string": ["query-string@5.1.1", "", { "dependencies": { "decode-uri-component": "^0.2.0", "object-assign": "^4.1.0", "strict-uri-encode": "^1.0.0" } }, "sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw=="],
|
||||
|
||||
"reg-cli/glob": ["glob@7.2.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.0.4", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q=="],
|
||||
|
||||
"reg-cli/make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="],
|
||||
@@ -8207,10 +8189,6 @@
|
||||
|
||||
"raw-body/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"react-dnd-html5-backend/dnd-core/redux": ["redux@4.2.0", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA=="],
|
||||
|
||||
"react-dnd/dnd-core/redux": ["redux@4.2.0", "", { "dependencies": { "@babel/runtime": "^7.9.2" } }, "sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA=="],
|
||||
|
||||
"react-docgen-typescript-plugin/find-cache-dir/make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="],
|
||||
|
||||
"react-docgen/@types/babel__core/@types/babel__traverse": ["@types/babel__traverse@7.14.2", "", { "dependencies": { "@babel/types": "^7.3.0" } }, "sha512-K2waXdXBi2302XUdcHcR1jCeU0LL4TD9HRs/gk0N2Xvrht+G/BfJa4QObBQZfhMdxiCpV3COl5Nfq4uKTeTnJA=="],
|
||||
|
||||
32
deps.edn
@@ -117,15 +117,15 @@
|
||||
org.apache.httpcomponents.client5/httpclient5 {:mvn/version "5.6.2"} ; pinned: must pair with httpcore5 5.4.x (upstream pairing); ClickHouse JDBC needs httpcore5 5.3.4+
|
||||
org.apache.httpcomponents.core5/httpcore5 {:mvn/version "5.4.3"} ; pinned: 5.4.2+
|
||||
org.apache.httpcomponents.core5/httpcore5-h2 {:mvn/version "5.4.3"} ; pinned: must match httpcore5 version
|
||||
org.apache.logging.log4j/log4j-1.2-api {:mvn/version "2.25.4"} ; apache logging framework
|
||||
org.apache.logging.log4j/log4j-api {:mvn/version "2.25.4"} ; add compatibility with log4j 1.2
|
||||
org.apache.logging.log4j/log4j-core {:mvn/version "2.25.4"} ; apache logging framework
|
||||
org.apache.logging.log4j/log4j-jcl {:mvn/version "2.25.4"} ; allows the commons-logging API to work with log4j 2
|
||||
org.apache.logging.log4j/log4j-jul {:mvn/version "2.25.4"} ; java.util.logging (JUL) -> Log4j2 adapter
|
||||
org.apache.logging.log4j/log4j-1.2-api {:mvn/version "2.26.1"} ; apache logging framework
|
||||
org.apache.logging.log4j/log4j-api {:mvn/version "2.26.1"} ; add compatibility with log4j 1.2
|
||||
org.apache.logging.log4j/log4j-core {:mvn/version "2.26.1"} ; apache logging framework
|
||||
org.apache.logging.log4j/log4j-jcl {:mvn/version "2.26.1"} ; allows the commons-logging API to work with log4j 2
|
||||
org.apache.logging.log4j/log4j-jul {:mvn/version "2.26.1"} ; java.util.logging (JUL) -> Log4j2 adapter
|
||||
org.apache.logging.log4j/log4j-layout-template-json
|
||||
{:mvn/version "2.25.4"} ; allows the custom json logging format
|
||||
{:mvn/version "2.26.1"} ; allows the custom json logging format
|
||||
org.apache.logging.log4j/log4j-slf4j2-impl
|
||||
{:mvn/version "2.25.4"} ; allows the slf4j2 API to work with log4j 2
|
||||
{:mvn/version "2.26.1"} ; allows the slf4j2 API to work with log4j 2
|
||||
org.apache.pdfbox/pdfbox {:mvn/version "3.0.5"} ; PDF generation (backend dashboard PDF prototype)
|
||||
org.apache.poi/poi {:mvn/version "5.5.1"} ; Work with Office documents (e.g. Excel spreadsheets) -- newer version than one specified by Docjure
|
||||
org.apache.poi/poi-ooxml {:mvn/version "5.5.1"
|
||||
@@ -161,10 +161,10 @@
|
||||
org.clojure/tools.namespace {:mvn/version "1.5.0"}
|
||||
org.clojure/tools.reader {:mvn/version "1.5.2"}
|
||||
org.clojure/tools.trace {:mvn/version "0.8.0"} ; function tracing
|
||||
org.eclipse.jetty/jetty-server {:mvn/version "12.1.7"}
|
||||
org.eclipse.jetty/jetty-unixdomain-server {:mvn/version "12.1.7"}
|
||||
org.eclipse.jetty.ee9/jetty-ee9-servlet {:mvn/version "12.1.7"}
|
||||
org.eclipse.jetty.ee9.websocket/jetty-ee9-websocket-jetty-server {:mvn/version "12.1.7"}
|
||||
org.eclipse.jetty/jetty-server {:mvn/version "12.1.11"}
|
||||
org.eclipse.jetty/jetty-unixdomain-server {:mvn/version "12.1.11"}
|
||||
org.eclipse.jetty.ee9/jetty-ee9-servlet {:mvn/version "12.1.11"}
|
||||
org.eclipse.jetty.ee9.websocket/jetty-ee9-websocket-jetty-server {:mvn/version "12.1.11"}
|
||||
org.eclipse.jgit/org.eclipse.jgit {:mvn/version "7.3.0.202506031305-r"}; Java implementation of Git version control system
|
||||
org.flatland/ordered {:mvn/version "1.15.12"} ; ordered maps & sets
|
||||
org.graalvm.polyglot/js-community {:mvn/version "25.0.1" :extension "pom"} ; JavaScript engine
|
||||
@@ -651,7 +651,15 @@
|
||||
;; https://clojurians.slack.com/archives/C03S1KBA2/p1667925853699669
|
||||
;; An issue has been filed here https://github.com/jonase/eastwood/issues/444
|
||||
;; and a simple reproduction repo can be found here https://github.com/metabase/snowplow-eastwood-issue
|
||||
:exclude-namespaces [metabase.analytics.snowplow]}}
|
||||
;;
|
||||
;; The two Quartz queue-affinity DriverDelegate shims are `gen-class` namespaces whose
|
||||
;; only body is a `(.superRtp this ...)` call to their own exposed super method. That
|
||||
;; method exists only on the class the namespace itself generates, so `this` can't be
|
||||
;; type-hinted with it at compile time (the class isn't defined yet) — the reflective
|
||||
;; call is unavoidable. Eastwood also can't reliably analyze these gen-class shims.
|
||||
:exclude-namespaces [metabase.analytics.snowplow
|
||||
metabase.mq.quartz-affinity-delegate-std
|
||||
metabase.mq.quartz-affinity-delegate-postgres]}}
|
||||
|
||||
;; Test counterpart of :eastwood. Starts with :reflection only; add more linters as we fix other eastwood
|
||||
;; issues in tests.
|
||||
|
||||
273
dev/src/dev/mq_perf.clj
Normal file
@@ -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
|
||||
@@ -26,7 +26,7 @@ Metabot is Metabase's built-in AI agent. Metabot can help you with most daily ta
|
||||
**Provider**: Choose from:
|
||||
|
||||
- Metabase's own AI Service (available as an add-on exclusively for Metabase Cloud)
|
||||
- Third-party models via an API key (Anthropic models only for now).
|
||||
- Third-party models via your own [provider API key](settings.md#bring-your-own-api-key)
|
||||
|
||||
**Plans**: available on all plans. You can only use Metabase's AI Service on Metabase Cloud.
|
||||
|
||||
|
||||
@@ -54,9 +54,15 @@ To disable Metabase AI provider and stop charges:
|
||||
|
||||
### Bring your own API key
|
||||
|
||||
You can specify your own API key and model for Metabot from one of the supported providers. Currently, Metabase only supports models from Anthropic.
|
||||
You can specify your own API key and model for Metabot from one of the supported providers. When you connect a provider, the **Model** dropdown lists the supported models that your API key can access.
|
||||
|
||||
If you're interested in Metabase supporting more AI providers, let us know by submitting a [feature request](../troubleshooting-guide/requesting-new-features.md).
|
||||
- **Amazon Bedrock**: Claude and GPT models.
|
||||
- **Anthropic**: Claude models (Opus, Sonnet, Haiku, and Fable).
|
||||
- **Microsoft Azure**: Your own Azure deployments of Claude or GPT models. Enter the deployment name you configured in Azure.
|
||||
- **OpenAI**: GPT models.
|
||||
- **OpenRouter**: Claude and GPT models.
|
||||
|
||||
If you're interested in Metabase supporting more AI providers or models, let us know by submitting a [feature request](../troubleshooting-guide/requesting-new-features.md).
|
||||
|
||||
To enable AI features with your own API key:
|
||||
|
||||
|
||||
1724
docs/api.json
generated
@@ -222,6 +222,8 @@ config:
|
||||
metabot-enabled: true
|
||||
metabot-recent-views-enabled: true
|
||||
metabot-slack-signing-secret: null
|
||||
mfa-challenge-signing-key: null
|
||||
mfa-enforcement: 'off'
|
||||
native-query-autocomplete-match-style: substring
|
||||
nested-field-columns-value-length-limit: 50000
|
||||
no-data-illustration: default
|
||||
@@ -242,6 +244,9 @@ config:
|
||||
premium-embedding-token: null
|
||||
query-caching-max-kb: 2000
|
||||
query-caching-max-ttl: 3024000.0
|
||||
queue-backend: quartz
|
||||
queue-max-retries: 5
|
||||
queue-no-listener-max-age-ms: 86400000
|
||||
redirect-all-requests-to-https: false
|
||||
remote-sync-auto-import: false
|
||||
remote-sync-auto-import-rate: 5
|
||||
|
||||
@@ -1301,7 +1301,7 @@ The Anthropic API Key.
|
||||
- Default: `anthropic/claude-sonnet-4-6`
|
||||
- [Configuration file name](./config-file.md): `llm-metabot-provider`
|
||||
|
||||
The AI provider and model for Metabot. Format: provider/model-name, e.g. `anthropic/claude-haiku-4-5`, `openai/gpt-5.4`, `openrouter/anthropic/claude-haiku-4-5`.
|
||||
The AI provider and model for Metabot. Format: provider/model-name, e.g. `anthropic/claude-haiku-4-5`, `openai/gpt-5.4`, `openrouter/anthropic/claude-haiku-4.5`.
|
||||
|
||||
### `MB_LOAD_ANALYTICS_CONTENT`
|
||||
|
||||
@@ -1394,6 +1394,21 @@ Whether the user's recently viewed items are included in the Metabot system prom
|
||||
|
||||
Signing secret for verifying requests from the Metabot Slack app.
|
||||
|
||||
### `MB_MFA_CHALLENGE_SIGNING_KEY`
|
||||
|
||||
- Type: string
|
||||
- Default: `null`
|
||||
|
||||
Key used to sign MFA challenge tokens. Generated automatically on first use.
|
||||
|
||||
### `MB_MFA_ENFORCEMENT`
|
||||
|
||||
- Type: keyword
|
||||
- Default: `off`
|
||||
- [Configuration file name](./config-file.md): `mfa-enforcement`
|
||||
|
||||
Controls whether two-factor authentication is available to users. :off disables it entirely; :optional allows users to enroll voluntarily.
|
||||
|
||||
### `MB_NATIVE_QUERY_AUTOCOMPLETE_MATCH_STYLE`
|
||||
|
||||
- Type: keyword
|
||||
@@ -1581,6 +1596,27 @@ The maximum size of the cache, per saved question, in kilobytes.
|
||||
|
||||
The absolute maximum time to keep any cached query results, in seconds.
|
||||
|
||||
### `MB_QUEUE_BACKEND`
|
||||
|
||||
- Type: string
|
||||
- Default: `quartz`
|
||||
|
||||
Which queue backend to use. Valid values: `quartz`, `memory`.
|
||||
|
||||
### `MB_QUEUE_MAX_RETRIES`
|
||||
|
||||
- Type: integer
|
||||
- Default: `5`
|
||||
|
||||
Maximum number of times a failed queue message will be retried before being dropped.
|
||||
|
||||
### `MB_QUEUE_NO_LISTENER_MAX_AGE_MS`
|
||||
|
||||
- Type: integer
|
||||
- Default: `86400000`
|
||||
|
||||
How long (in milliseconds) a Quartz queue message with no listener anywhere in the cluster is kept before the reaper drops it. With node-affinity, such a message is never acquired and just waits in the store; this bounds that wait so a message for a queue no node handles (e.g. a new queue whose node was rolled back) does not linger forever. Generous by default (1 day) so a node can restart or be re-upgraded and still deliver it.
|
||||
|
||||
### `MB_REDIRECT_ALL_REQUESTS_TO_HTTPS`
|
||||
|
||||
- Type: boolean
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@ title: Driver interface changelog
|
||||
|
||||
# Driver Interface Changelog
|
||||
|
||||
## Metabase 0.63.0
|
||||
## Metabase 0.64.0
|
||||
|
||||
- Added a `:native-pivot-tables` driver feature flag for drivers that can compile a pivot query as a single
|
||||
`GROUP BY GROUPING SETS (...)` statement instead of the legacy multi-query path. Drivers that opt in must also
|
||||
derive from `:sql-mbql5` (which provides the `:pivot` clause compiler). Defaults to `false`.
|
||||
|
||||
- Index Manager: drivers can now read and create table indexes, in the broad sense (secondary indexes, sort keys,
|
||||
distribution keys, clustering, etc.). New driver feature flags:
|
||||
@@ -34,8 +38,10 @@ title: Driver interface changelog
|
||||
- `metabase.driver/compile-create-index` `[driver schema table structured]` -- compiles a `:standalone` index into
|
||||
the DDL statement(s) that create it.
|
||||
|
||||
- `metabase.driver/refresh-table-stats!` `[driver database schema table transform-type]` -- refreshes table
|
||||
statistics (e.g. `ANALYZE`) after a transform run. Defaults to a no-op.
|
||||
## Metabase 0.63.0
|
||||
|
||||
- `metabase.driver/refresh-table-stats!` `[driver database schema table transform-type]` -- refreshes table
|
||||
statistics (e.g. `ANALYZE`) after a transform run. Defaults to a no-op.
|
||||
|
||||
- `metabase.driver/describe-table-fks`, deprecated in 0.49.0, has been removed. Please implement
|
||||
`metabase.driver/describe-fks` instead. This method is now required for drivers that support
|
||||
|
||||
@@ -281,6 +281,8 @@ app.get("/sso/metabase", async (req, res) => {
|
||||
});
|
||||
```
|
||||
|
||||
For modular embedding, Metabase exchanges the JWT via `POST /auth/sso` with a JSON body (not a `GET` redirect). Full app embedding typically uses the `GET` redirect shown above. See [JWT-based authentication](../people-and-groups/authenticating-with-jwt.md).
|
||||
|
||||
## Embedding Metabase in a different domain
|
||||
|
||||
{% include plans-blockquote.html feature="Authenticated modular embeds" convert_pro_link_to_embedding=true is_plural=true %}
|
||||
|
||||
@@ -109,6 +109,8 @@ If you're using [JWT](../people-and-groups/authenticating-with-jwt.md), you can
|
||||
https://metabase.example.com/auth/sso?jwt=<token>&return_to=%2Fdashboard%2F1
|
||||
```
|
||||
|
||||
To avoid putting the JWT in the URL, you can also authenticate with a `POST` request and a JSON body. See [JWT-based authentication](../people-and-groups/authenticating-with-jwt.md).
|
||||
|
||||
You must URL encode (or double encode, depending on your web setup) all of the parameters in your redirect link, including parameters for filters (e.g., `filter=value`) and [UI settings](#showing-or-hiding-metabase-ui-components) (e.g., `top_nav=true`). For example, if you added two filter parameters to the JWT example shown above, your `src` link would become:
|
||||
|
||||
```
|
||||
|
||||
@@ -197,6 +197,8 @@ See [Modular embedding parameters](./parameters.md#pass-parameter-values-to-embe
|
||||
|
||||
Locked parameters let you filter data without exposing the filter to the end-user. Locking parameters is useful for restricting data based on who's viewing the embed (for example, showing each customer only their own data).
|
||||
|
||||

|
||||
|
||||
To use locked parameters, you need to:
|
||||
|
||||
1. Set the parameter to **Locked** in the embed settings.
|
||||
|
||||
BIN
docs/embedding/images/locked-parameters.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
@@ -308,6 +308,8 @@ For an overview of how data permissions work in Metabase, see [Data permissions]
|
||||
|
||||
If every tenant has their data in a separate database, then instead of using permissions for data access control, you can use [**database routing**](../permissions/database-routing.md) to route queries to appropriate databases directly.
|
||||
|
||||
For a comparison of data isolation methods, see [Choosing a data isolation method](../permissions/data-isolation-methods.md).
|
||||
|
||||
- **Create queries** controls whether tenant users can create queries on the data they see. If you want to give your tenant users the ability to drill through (e.g., through `drills` parameter in [modular embedding](../embedding/modular-embedding.md)), you need to give them "Create queries" permissions, because a drill through is a new query.
|
||||
|
||||
- **Download results** controls, unsurprisingly, whether people can download results of queries. You need to set download permissions if you want to give your users the option to download their data as a spreadsheet (for example, through `with-downloads` parameter in [modular embedding](../embedding/modular-embedding.md)).
|
||||
@@ -437,3 +439,4 @@ If you disable multi-tenant strategy, _all your tenant users will be deactivated
|
||||
- [Embedding overview](./start.md)
|
||||
- [JWT authentication](../people-and-groups/authenticating-with-jwt.md)
|
||||
- [Permissions overview](../permissions/start.md)
|
||||
- [Data isolation methods](../permissions/data-isolation-methods.md)
|
||||
|
||||
@@ -45,7 +45,7 @@ You may already have Java installed. To check the version, open a terminal and r
|
||||
java -version
|
||||
```
|
||||
|
||||
If Java isn't installed, you'll need to install Java before you can run Metabase. We recommend version 21 of JRE from [Eclipse Temurin](https://adoptium.net/) with HotSpot JVM. You can run Metabase wherever Java 21 runs. Earlier Java versions aren't supported. The particular processor architecture shouldn't matter (although we only test Metabase for x86 and ARM).
|
||||
If Java isn't installed, you'll need to install Java before you can run Metabase. We recommend version 25 of JRE from [Eclipse Temurin](https://adoptium.net/) with HotSpot JVM. You can run Metabase wherever Java 25 runs. Earlier Java versions aren't supported. The particular processor architecture shouldn't matter (although we only test Metabase for x86 and ARM).
|
||||
|
||||
### 2. Download Metabase
|
||||
|
||||
|
||||
@@ -22,7 +22,30 @@ Assuming your site is localhost serving on port 3000:
|
||||
5. In the event of a successful sign-in, your authentication app should issue a GET request to your Metabase endpoint with the token and the "return to" URI: `http://localhost:3000/auth/sso?jwt=TOKEN_GOES_HERE&return_to=/question/1-superb-question`.
|
||||
6. Metabase verifies the JSON Web Token, logs the person in, then redirects the person to their original destination, `/question/1-superb-question`.
|
||||
|
||||
As an alternative to putting the JWT in the URL query string (which some environments log or cache), you can send the same token in the body of a `POST` request to `/auth/sso` with `Content-Type: application/json` and a JSON body like `{"jwt": "TOKEN_GOES_HERE"}`. You can still pass `return_to` as a query parameter on that URL (for example `POST /auth/sso?return_to=/question/1-superb-question`). The login behavior is otherwise the same as the GET flow.
|
||||
- For full app embeds, use a `GET` request.
|
||||
- For modular embeds use a `POST` request with a JSON body (to avoid putting the JWT in the URL). You'll also want to use `POST` requests in contexts that log or cache URLs, such as server-side integrations or testing.
|
||||
|
||||
In both cases the login behavior is the same, and you can pass `return_to` as a query parameter in both `GET` and `POST` requests.
|
||||
|
||||
An example `POST` request with curl:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/auth/sso?return_to=/question/1-superb-question" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jwt": "TOKEN_GOES_HERE"}'
|
||||
```
|
||||
|
||||
Or with JavaScript:
|
||||
|
||||
```js
|
||||
await fetch(`${METABASE_URL}/auth/sso?return_to=/question/1-superb-question`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jwt: token }),
|
||||
credentials: "include",
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
## Set up JWT authentication
|
||||
|
||||
|
||||
90
docs/permissions/data-isolation-methods.md
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
title: Data isolation methods
|
||||
summary: Compare the ways Metabase can restrict data access for internal analytics or multi-tenant setups, including row and column security, impersonation, database routing, and locked parameters.
|
||||
---
|
||||
|
||||
# Data isolation methods
|
||||
|
||||
> Row and column security, impersonation, and database routing are only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
|
||||
|
||||
Metabase offers several ways to ensure people only see the data they're supposed to see, whether you're restricting data by team for internal analytics or isolating customer data in a multi-tenant setup. The best method depends on how you store your data and where you want to enforce access rules:
|
||||
|
||||
- If all data lives in the same tables, use [row and column security](#row-and-column-security) to filter data in Metabase, or [impersonation](#impersonation) to enforce access rules at the database level.
|
||||
- If each customer or team has their own database, use [database routing](#database-routing).
|
||||
- If you're embedding dashboards for people without Metabase accounts, use [locked parameters](#locked-parameters).
|
||||
|
||||
You can combine these methods. For example, you can use locked parameters for a guest embed while using row and column security for signed-in users.
|
||||
|
||||
## Row and column security
|
||||
|
||||
[Row and column security](./row-and-column-security.md) lets you control which rows and columns each group can see. You can define rules that filter data based on [user attributes](../people-and-groups/managing.md#adding-a-user-attribute). Metabase applies your rules to questions you build in the query builder.
|
||||
|
||||

|
||||
|
||||
Use row and column security when:
|
||||
|
||||
- You need granular control over which rows and columns each group or customer can see.
|
||||
- You want to manage access rules in the Metabase UI instead of in your database.
|
||||
|
||||
Don't use row and column security when:
|
||||
|
||||
- People need to write native SQL queries. Row and column security doesn't apply to the SQL editor.
|
||||
- You need column-level security on MongoDB or Druid, which only support row-level security.
|
||||
|
||||
## Impersonation
|
||||
|
||||
[Impersonation](./impersonation.md) lets your database control what each person can see. You define roles and row-level security policies in your database. Metabase connects using the role specified by each person's [user attribute](../people-and-groups/managing.md#adding-a-user-attribute), and your database enforces what that role can see.
|
||||
|
||||

|
||||
|
||||
Use impersonation when:
|
||||
|
||||
- You want to centralize access logic in your database instead of in Metabase.
|
||||
- People need to write native SQL queries. Since your database enforces the rules, the rules apply in the SQL editor too.
|
||||
|
||||
Don't use impersonation when:
|
||||
|
||||
- Your database [doesn't support it](./impersonation.md#databases-that-support-impersonation).
|
||||
- You don't want to set up and maintain roles and policies in your database.
|
||||
- You rely heavily on caching. You can't share cached results between roles.
|
||||
|
||||
## Database routing
|
||||
|
||||
[Database routing](./database-routing.md) sends each person's queries to their own database. You build questions and dashboards once against a primary database, and Metabase routes queries to the right destination database based on each person's [user attribute](../people-and-groups/managing.md#adding-a-user-attribute). Database routing requires that each group's data lives in a separate database, and that all destination databases have identical schemas.
|
||||
|
||||

|
||||
|
||||
Use database routing when:
|
||||
|
||||
- You want the strongest isolation. Each group's data lives in a separate database.
|
||||
- You want to avoid one group's queries affecting the performance of another group's queries.
|
||||
|
||||
Don't use database routing when:
|
||||
|
||||
- Groups share tables. Use [row and column security](#row-and-column-security) or [impersonation](#impersonation) instead.
|
||||
- You want to manage access rules in the Metabase UI instead of maintaining a separate database for each group.
|
||||
|
||||
## Locked parameters
|
||||
|
||||
[Locked parameters](../embedding/guest-embedding.md#locked-parameters) filter an embedded dashboard or question before Metabase displays it. Your app passes the filter value in a signed JWT. The filter is invisible to the person viewing the embed, and they can't change it.
|
||||
|
||||

|
||||
|
||||
Use locked parameters when:
|
||||
|
||||
- You want to restrict data without setting up Metabase accounts, user attributes, or permissions.
|
||||
- You want to filter data without exposing the filter or its values to the people viewing the embed.
|
||||
|
||||
Don't use locked parameters when:
|
||||
|
||||
- You want account-level or role-based access control across your Metabase.
|
||||
- You're working with sensitive data. Use a method that enforces access at the permissions or database level instead, like [row and column security](#row-and-column-security), [impersonation](#impersonation), or [database routing](#database-routing).
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Permissions overview](./start.md)
|
||||
- [Tenants](../embedding/tenants.md)
|
||||
- [Row and column security](./row-and-column-security.md)
|
||||
- [Impersonation](./impersonation.md)
|
||||
- [Database routing](./database-routing.md)
|
||||
- [Locked parameters for guest embeds](../embedding/guest-embedding.md#locked-parameters)
|
||||
@@ -9,6 +9,8 @@ summary: Route queries to different databases based on who's viewing them. Great
|
||||
|
||||
With database routing, an admin can build a question once using one database, and the question will run its query against a different database with the same schema depending on who is viewing the question.
|
||||
|
||||

|
||||
|
||||
Database routing is useful for:
|
||||
|
||||
- Managing modular and full app embedding setups where each customer has their own database with identical schemas.
|
||||
|
||||
BIN
docs/permissions/images/connection-impersonation.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
docs/permissions/images/database-routing-diagram.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
docs/permissions/images/row-and-column-level-security.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
@@ -10,6 +10,8 @@ This page covers the [View data](./data.md#view-data-permissions) permission lev
|
||||
|
||||
**Impersonation access** allows admins to "outsource" View data permissions to roles in your database. Admins can associate user attributes with database-defined roles and their privileges. If someone is in a group with their View data permission set to Impersonation, the person will be able to view and query data based on the privileges granted to the role specified by their user attribute.
|
||||
|
||||

|
||||
|
||||
## Databases that support impersonation
|
||||
|
||||
For now, impersonation access is only available for the following databases:
|
||||
|
||||
@@ -14,6 +14,8 @@ Row and column security lets you give granular permissions for different groups
|
||||
|
||||
You can use row and column security to set up [self-service analytics](https://www.metabase.com/learn/metabase-basics/embedding/multi-tenant-self-service-analytics), so that each of your customers can only view the rows that match their customer ID. For example, if you have an Accounts table with information about your customers, you can add permissions to the table so that each customer only sees the data relevant to them.
|
||||
|
||||

|
||||
|
||||
> Row and column security was formerly called data sandboxing. It's the same feature, it just now has a more descriptive name.
|
||||
|
||||
## Row and column security examples
|
||||
|
||||
@@ -28,6 +28,10 @@ Granting group access to different Metabase features.
|
||||
|
||||
Setting up row and column security to restrict access to rows and columns in tables.
|
||||
|
||||
## [Choosing a data isolation method](./data-isolation-methods.md)
|
||||
|
||||
Compare row and column security, impersonation, database routing, and locked parameters for multi-tenant setups.
|
||||
|
||||
## [Snippet folder permissions](./snippets.md)
|
||||
|
||||
Organize snippets into folders that require permissions to view.
|
||||
|
||||
@@ -109,6 +109,8 @@ You can export a dashboard as a PDF. Click on the **Sharing** button, then selec
|
||||
|
||||
The PDF will only include screenshots of the charts as they are visible on the dashboard.
|
||||
|
||||
To get a PDF of a dashboard delivered on a schedule, [attach a PDF to a dashboard subscription](../dashboards/subscriptions.md#attach-a-pdf-of-the-dashboard).
|
||||
|
||||
### Exporting results of a dashboard card
|
||||
|
||||
To export the results of a particular card, hover over the dashboard card, click on the three dot menu (**...**), and select **Download results**.
|
||||
@@ -128,7 +130,7 @@ If you don't see this option, you may not have [permissions to download results]
|
||||
|
||||
### Exporting results of a dashboard via dashboard subscriptions
|
||||
|
||||
You can use [dashboard subscriptions](../dashboards/subscriptions.md) to regularly export data from all questions on a dashboard, and include those results as an attachment.
|
||||
You can use [dashboard subscriptions](../dashboards/subscriptions.md) to regularly export data from all questions on a dashboard, and include those results as an attachment. You can also [attach a PDF](../dashboards/subscriptions.md#attach-a-pdf-of-the-dashboard) of the entire dashboard.
|
||||
|
||||
## Remove Metabase branding from exports
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ Metabase runs on the Java Virtual Machine (JVM), and depending on how it's confi
|
||||
|
||||
## Java version
|
||||
|
||||
Metabase should be run on Java version 21 (older versions are unsupported).
|
||||
Metabase should be run on Java version 25 (older versions are unsupported).
|
||||
|
||||
When searching for versions of Java, always use the latest minor version of the major version you're choosing. E.g., when choosing between Java 21.0.1 and Java 21.0.4, choose the latest version (in this case, 21.0.4).
|
||||
When searching for versions of Java, always use the latest minor version of the major version you're choosing. E.g., when choosing between Java 25.0.1 and Java 25.0.3, choose the latest version (in this case, 25.0.3).
|
||||
|
||||
We recommend running only one version of Java on a single server, because running more than one version of Java on a single server can cause application problems. If you need to run multiple applications that each require a different java version, consider using containers (as containers were meant to solve this problem). Otherwise, just make sure that you can run all your applications with a single Java version.
|
||||
|
||||
|
||||
@@ -604,6 +604,9 @@ The Topic column on the [Activity log](#activity-log) model takes one of:
|
||||
- measure-create
|
||||
- measure-delete
|
||||
- measure-update
|
||||
- mfa-disabled
|
||||
- mfa-enrolled
|
||||
- mfa-verification-failed
|
||||
- notification-create
|
||||
- notification-unsubscribe
|
||||
- notification-unsubscribe-ex
|
||||
|
||||
@@ -6,6 +6,7 @@ export type SecurityAdvisorySpec = {
|
||||
remediation: string;
|
||||
advisory_url?: string | null;
|
||||
affected_versions: { min: string; fixed: string }[];
|
||||
download_jar_urls?: { version: string; url: string }[];
|
||||
matching_query?: Record<string, string> | null;
|
||||
match_status: "unknown" | "active" | "resolved" | "not_affected" | "error";
|
||||
published_at: string;
|
||||
|
||||
@@ -177,13 +177,18 @@ export function visitMetric(id) {
|
||||
* @param {number|string} dashboardIdOrAlias
|
||||
* @param {Object} config
|
||||
*/
|
||||
export function visitDashboard(dashboardIdOrAlias, { params = {} } = {}) {
|
||||
export function visitDashboard(
|
||||
dashboardIdOrAlias,
|
||||
{ params = {}, dashcardTimeout } = {},
|
||||
) {
|
||||
if (typeof dashboardIdOrAlias === "number") {
|
||||
visitDashboardById(dashboardIdOrAlias, { params });
|
||||
visitDashboardById(dashboardIdOrAlias, { params, dashcardTimeout });
|
||||
}
|
||||
|
||||
if (typeof dashboardIdOrAlias === "string") {
|
||||
cy.get(dashboardIdOrAlias).then((id) => visitDashboardById(id, { params }));
|
||||
cy.get(dashboardIdOrAlias).then((id) =>
|
||||
visitDashboardById(id, { params, dashcardTimeout }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +241,12 @@ function visitDashboardById(dashboard_id, config) {
|
||||
qs: config.params,
|
||||
});
|
||||
|
||||
cy.wait(aliases);
|
||||
// dashcardTimeout lets callers widen the per-query wait window for dashboards with
|
||||
// many cards, whose tail queries stagger past cy.wait's 5s default under CPU load
|
||||
cy.wait(
|
||||
aliases,
|
||||
config.dashcardTimeout ? { timeout: config.dashcardTimeout } : {},
|
||||
);
|
||||
} else {
|
||||
// For a dashboard:
|
||||
// - without questions (can be empty or markdown only) or
|
||||
|
||||
@@ -48,6 +48,12 @@ export function restore(name = "default") {
|
||||
resetWritableDb({ type: dbType });
|
||||
}
|
||||
|
||||
cy.request({
|
||||
method: "POST",
|
||||
url: "/api/testing/reset-throttlers",
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
|
||||
return cy.request("POST", `/api/testing/restore/${name}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
/**
|
||||
* @param {object} segment
|
||||
* @param {string} segment.name
|
||||
* @param {TableId} segment.table_id
|
||||
* @param {string|null} [segment.description]
|
||||
* @param {object} segment.definition
|
||||
*/
|
||||
export const createSegment = ({
|
||||
name,
|
||||
table_id,
|
||||
definition,
|
||||
description = null,
|
||||
}) => {
|
||||
export const createSegment = ({ name, definition, description = null }) => {
|
||||
cy.log(`Create a segment: ${name}`);
|
||||
return cy.request("POST", "/api/segment", {
|
||||
name,
|
||||
description,
|
||||
table_id,
|
||||
definition,
|
||||
});
|
||||
};
|
||||
@@ -23,21 +16,14 @@ export const createSegment = ({
|
||||
/**
|
||||
* @param {object} measure
|
||||
* @param {string} measure.name
|
||||
* @param {TableId} measure.table_id
|
||||
* @param {string|null} [measure.description]
|
||||
* @param {object} measure.definition
|
||||
*/
|
||||
export const createMeasure = ({
|
||||
name,
|
||||
table_id,
|
||||
definition,
|
||||
description = null,
|
||||
}) => {
|
||||
export const createMeasure = ({ name, definition, description = null }) => {
|
||||
cy.log(`Create a measure: ${name}`);
|
||||
return cy.request("POST", "/api/measure", {
|
||||
name,
|
||||
description,
|
||||
table_id,
|
||||
definition,
|
||||
});
|
||||
};
|
||||
@@ -46,21 +32,19 @@ export const createMeasure = ({
|
||||
* @param {object} measure
|
||||
* @param {MeasureId} measure.id
|
||||
* @param {string} [measure.name]
|
||||
* @param {TableId} [measure.table_id]
|
||||
* @param {string|null} [measure.description]
|
||||
* @param {object} [measure.definition]
|
||||
* @param {object} [options]
|
||||
* @param {string|null} [options.revision_message]
|
||||
*/
|
||||
export const updateMeasure = (
|
||||
{ id, name, table_id, definition, description = null },
|
||||
{ id, name, definition, description = null },
|
||||
{ revision_message = "Update measure" } = {},
|
||||
) => {
|
||||
cy.log(`Update a measure: ${id}`);
|
||||
return cy.request("PUT", `/api/measure/${id}`, {
|
||||
name,
|
||||
description,
|
||||
table_id,
|
||||
definition,
|
||||
revision_message,
|
||||
});
|
||||
|
||||
@@ -76,6 +76,7 @@ export function waitForSucceededTransformRuns() {
|
||||
|
||||
export function createMbqlTransform({
|
||||
sourceTable,
|
||||
sourceSchema,
|
||||
targetTable,
|
||||
targetSchema,
|
||||
tagIds,
|
||||
@@ -85,6 +86,7 @@ export function createMbqlTransform({
|
||||
collectionId,
|
||||
}: {
|
||||
sourceTable: string;
|
||||
sourceSchema?: string | null;
|
||||
targetTable: string;
|
||||
targetSchema: string | null;
|
||||
tagIds?: TransformTagId[];
|
||||
@@ -93,7 +95,11 @@ export function createMbqlTransform({
|
||||
visitTransform?: boolean;
|
||||
collectionId?: CollectionId | null;
|
||||
}) {
|
||||
return getTableId({ databaseId, name: sourceTable }).then((tableId) => {
|
||||
return getTableId({
|
||||
databaseId,
|
||||
name: sourceTable,
|
||||
schema: sourceSchema ?? undefined,
|
||||
}).then((tableId) => {
|
||||
return createTransform(
|
||||
{
|
||||
name,
|
||||
|
||||
352
e2e/test/scenarios/admin-2/multi-factor-auth.cy.spec.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
const { H } = cy;
|
||||
import * as OTPAuth from "otpauth";
|
||||
|
||||
import { USERS } from "e2e/support/cypress_data";
|
||||
import { NORMAL_USER_ID } from "e2e/support/cypress_sample_instance_data";
|
||||
|
||||
const { normal } = USERS;
|
||||
|
||||
const NEW_PASSWORD = "NewPassword2fa!123";
|
||||
|
||||
type MaildevEmail = { subject: string; html: string };
|
||||
|
||||
describe("scenarios > admin > settings > multi-factor authentication", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
H.clearInbox();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
cy.intercept("PUT", "/api/setting/mfa-enforcement").as("updateEnforcement");
|
||||
cy.intercept("POST", "/api/ee/mfa/enroll").as("enroll");
|
||||
});
|
||||
|
||||
it("admin can enable and disable 2FA in authentication settings", () => {
|
||||
cy.visit("/admin/settings/authentication");
|
||||
mfaSetting().scrollIntoView();
|
||||
mfaSetting().findByText("Two-factor authentication").should("be.visible");
|
||||
mfaToggle().should("not.be.checked").click();
|
||||
cy.wait("@updateEnforcement");
|
||||
mfaSetting()
|
||||
.should("contain", "0 enrolled users")
|
||||
.and("contain", "users without 2FA");
|
||||
|
||||
cy.log("Disable it again");
|
||||
mfaToggle().should("be.checked").click();
|
||||
cy.wait("@updateEnforcement");
|
||||
mfaToggle().should("not.be.checked");
|
||||
mfaSetting().should("not.contain", "enrolled");
|
||||
});
|
||||
|
||||
it("user can set up 2FA in account settings and sign in with an authenticator code", () => {
|
||||
let totpSecret = "";
|
||||
|
||||
enableMfa();
|
||||
|
||||
cy.log("User enrolls from account security settings");
|
||||
cy.signInAsNormalUser();
|
||||
cy.visit("/account/security");
|
||||
cy.findByTestId("account-header")
|
||||
.findByRole("tab", { name: "Security" })
|
||||
.should("be.visible");
|
||||
enrollViaUI().then((secret) => {
|
||||
totpSecret = secret;
|
||||
});
|
||||
cy.findByRole("button", { name: "Disable" }).should("be.visible");
|
||||
cy.findByRole("button", { name: "Generate recovery codes" }).should(
|
||||
"be.visible",
|
||||
);
|
||||
|
||||
cy.log("Signing in now requires an authenticator code");
|
||||
signInWithPassword();
|
||||
cy.findByTestId("login-page")
|
||||
.findByText("Enter the 6-digit code from your authenticator app.")
|
||||
.should("be.visible");
|
||||
// The backend rejects a reused TOTP time step, so take the code for the
|
||||
// next 30-second window — validation accepts one step of clock skew.
|
||||
cy.then(() => generateTotpCode(totpSecret, Date.now() / 1000 + 30)).then(
|
||||
(code) => {
|
||||
cy.findByLabelText("Authenticator code").type(code);
|
||||
},
|
||||
);
|
||||
cy.button("Verify").click();
|
||||
cy.findByTestId("greeting-message").should("be.visible");
|
||||
});
|
||||
|
||||
it("user can disable 2FA themselves and re-enroll", () => {
|
||||
enableMfa();
|
||||
enrollNormalUser().then(({ secret }) => {
|
||||
cy.log("Disabling requires a fresh second factor, not just a password");
|
||||
cy.visit("/account/security");
|
||||
cy.findByRole("button", { name: "Disable" }).click();
|
||||
H.modal().within(() => {
|
||||
cy.findByText(
|
||||
"Are you sure you want to disable two-factor authentication? Your account will be protected by your password only, and your recovery codes will stop working.",
|
||||
).should("be.visible");
|
||||
cy.findByLabelText(
|
||||
"Confirm with an authenticator code or a recovery code",
|
||||
).type(generateTotpCode(secret, Date.now() / 1000 + 30));
|
||||
cy.button("Disable").click();
|
||||
});
|
||||
});
|
||||
cy.findByRole("button", {
|
||||
name: "Set up two-factor authentication",
|
||||
}).should("be.enabled");
|
||||
|
||||
cy.log("Re-enroll from scratch with a new secret");
|
||||
enrollViaUI();
|
||||
cy.findByRole("button", { name: "Disable" }).should("be.visible");
|
||||
});
|
||||
|
||||
it("recovery codes sign the user in once and regeneration invalidates the old set", () => {
|
||||
enableMfa();
|
||||
cy.intercept("POST", "/api/ee/mfa/recovery-codes").as("regenerate");
|
||||
|
||||
enrollNormalUser().then(({ secret, recoveryCodes }) => {
|
||||
cy.log("Sign in with a recovery code instead of an authenticator code");
|
||||
signInWithPassword();
|
||||
cy.findByTestId("login-page")
|
||||
.findByText("Use a recovery code instead")
|
||||
.click();
|
||||
cy.findByLabelText("Recovery code").type(recoveryCodes[0]);
|
||||
cy.button("Verify").click();
|
||||
cy.findByTestId("greeting-message").should("be.visible");
|
||||
|
||||
cy.log("Regenerate the recovery codes");
|
||||
cy.visit("/account/security");
|
||||
cy.findByRole("button", { name: "Generate recovery codes" }).click();
|
||||
H.modal().within(() => {
|
||||
cy.findByText(
|
||||
"This will generate a new set of recovery codes and invalidate all of your old ones.",
|
||||
).should("be.visible");
|
||||
cy.findByLabelText(
|
||||
"Confirm with an authenticator code or a recovery code",
|
||||
).type(generateTotpCode(secret, Date.now() / 1000 + 30));
|
||||
cy.button("Generate new codes").click();
|
||||
cy.findByText("Your recovery codes").should("be.visible");
|
||||
cy.button("Done").click();
|
||||
});
|
||||
|
||||
cy.log("Old codes no longer work; new ones do");
|
||||
cy.wait("@regenerate").then(({ response }) => {
|
||||
const newCodes = response?.body.recovery_codes;
|
||||
signInWithPassword();
|
||||
cy.findByTestId("login-page")
|
||||
.findByText("Use a recovery code instead")
|
||||
.click();
|
||||
cy.findByLabelText("Recovery code").type(recoveryCodes[1]);
|
||||
cy.button("Verify").click();
|
||||
cy.findByTestId("login-page")
|
||||
.findByRole("alert")
|
||||
.should("contain", "Invalid authentication code.");
|
||||
cy.findByLabelText("Recovery code").clear().type(newCodes[0]);
|
||||
// the submit button transiently reads "Failed" after the rejected
|
||||
// attempt but stays clickable, so match either label
|
||||
cy.button(/Verify|Failed/).click();
|
||||
cy.findByTestId("greeting-message").should("be.visible");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("an emailed one-time code works as a fallback second factor", () => {
|
||||
enableMfa();
|
||||
H.setupSMTP();
|
||||
enrollNormalUser();
|
||||
|
||||
signInWithPassword();
|
||||
cy.findByTestId("login-page")
|
||||
.findByText("Enter the 6-digit code from your authenticator app.")
|
||||
.should("be.visible");
|
||||
|
||||
cy.findByTestId("login-page").findByText("Email me a code").click();
|
||||
cy.findByTestId("login-page")
|
||||
.findByText("Code sent — check your email")
|
||||
.should("be.visible");
|
||||
|
||||
H.getInbox().then(({ body: emails }: { body: MaildevEmail[] }) => {
|
||||
const otpEmail = emails.find((email) =>
|
||||
email.subject.includes("Your sign-in code"),
|
||||
);
|
||||
expect(otpEmail, "sign-in code email").to.exist;
|
||||
const code = otpEmail?.html.match(/>\s*(\d{6})\s*</)?.[1];
|
||||
expect(code, "6-digit code in the email body").to.be.a("string");
|
||||
cy.findByLabelText("Authenticator code").type(String(code));
|
||||
});
|
||||
cy.button("Verify").click();
|
||||
cy.findByTestId("greeting-message").should("be.visible");
|
||||
});
|
||||
|
||||
it("resetting a forgotten password does not bypass the second factor", () => {
|
||||
enableMfa();
|
||||
H.setupSMTP();
|
||||
enrollNormalUser().then(({ secret }) => {
|
||||
cy.log("Request a reset link and set a new password");
|
||||
cy.signOut();
|
||||
cy.visit("/auth/forgot_password");
|
||||
cy.findByLabelText("Email address").type(normal.email);
|
||||
cy.button("Send password reset email").click();
|
||||
cy.findByTestId("login-page")
|
||||
.findByText(/If the email exists/)
|
||||
.should("be.visible");
|
||||
|
||||
// the reset email is sent asynchronously and lands next to the "2FA
|
||||
// enabled" notification from enrollment — wait for both to be there
|
||||
H.getInbox(2).then(({ body: emails }: { body: MaildevEmail[] }) => {
|
||||
const resetEmail = emails.find((email) =>
|
||||
email.subject.includes("Password Reset"),
|
||||
);
|
||||
expect(resetEmail, "password reset email").to.exist;
|
||||
cy.visit(getResetLink(String(resetEmail?.html)));
|
||||
});
|
||||
cy.findByLabelText("Create a password").type(NEW_PASSWORD);
|
||||
cy.findByLabelText("Confirm your password").type(NEW_PASSWORD);
|
||||
cy.button("Save new password").click();
|
||||
|
||||
cy.log("No session is minted — the new password still needs a code");
|
||||
cy.url().should("contain", "/auth/login");
|
||||
cy.findByLabelText("Email address").type(normal.email);
|
||||
cy.findByLabelText("Password").type(NEW_PASSWORD);
|
||||
cy.button("Sign in").click();
|
||||
cy.findByTestId("login-page")
|
||||
.findByText("Enter the 6-digit code from your authenticator app.")
|
||||
.should("be.visible");
|
||||
cy.then(() => generateTotpCode(secret, Date.now() / 1000 + 30)).then(
|
||||
(code) => {
|
||||
cy.findByLabelText("Authenticator code").type(code);
|
||||
},
|
||||
);
|
||||
cy.button("Verify").click();
|
||||
cy.findByTestId("greeting-message").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("an enrolled user is still challenged and can disable 2FA after the license lapses", () => {
|
||||
enableMfa();
|
||||
enrollNormalUser().then(({ secret, recoveryCodes }) => {
|
||||
cy.log("Drop the premium token — the gate must fail closed");
|
||||
cy.signInAsAdmin();
|
||||
H.deleteToken();
|
||||
|
||||
signInWithPassword();
|
||||
cy.findByTestId("login-page")
|
||||
.findByText("Enter the 6-digit code from your authenticator app.")
|
||||
.should("be.visible");
|
||||
cy.then(() => generateTotpCode(secret, Date.now() / 1000 + 30)).then(
|
||||
(code) => {
|
||||
cy.findByLabelText("Authenticator code").type(code);
|
||||
},
|
||||
);
|
||||
cy.button("Verify").click();
|
||||
cy.findByTestId("greeting-message").should("be.visible");
|
||||
|
||||
cy.log("Managing the existing enrollment still works without a license");
|
||||
cy.visit("/account/security");
|
||||
cy.findByRole("button", { name: "Disable" }).click();
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText(
|
||||
"Confirm with an authenticator code or a recovery code",
|
||||
).type(recoveryCodes[0]);
|
||||
cy.button("Disable").click();
|
||||
});
|
||||
|
||||
cy.log("Without the feature there is no way back into setup");
|
||||
cy.url().should("contain", "/account/profile");
|
||||
cy.visit("/account/security");
|
||||
cy.findByRole("button", {
|
||||
name: "Set up two-factor authentication",
|
||||
}).should("be.disabled");
|
||||
});
|
||||
});
|
||||
|
||||
it("admin can remove a user's enrollment to unlock them", () => {
|
||||
enableMfa();
|
||||
enrollNormalUser();
|
||||
|
||||
cy.log("Admin sees the enrollment and removes it (lockout escape hatch)");
|
||||
cy.signInAsAdmin();
|
||||
cy.visit("/admin/settings/authentication");
|
||||
mfaSetting().scrollIntoView().should("contain", "1 enrolled user");
|
||||
cy.request("POST", "/api/ee/mfa/admin/remove", {
|
||||
user_id: NORMAL_USER_ID,
|
||||
});
|
||||
|
||||
cy.log("After the reset the user signs in with just a password");
|
||||
signInWithPassword();
|
||||
cy.findByTestId("greeting-message").should("be.visible");
|
||||
cy.url().should("not.contain", "/auth/login");
|
||||
});
|
||||
});
|
||||
|
||||
function mfaSetting() {
|
||||
return cy.findByTestId("mfa-setting");
|
||||
}
|
||||
|
||||
function mfaToggle() {
|
||||
return mfaSetting().findByLabelText(/Enabled|Disabled/);
|
||||
}
|
||||
|
||||
function enableMfa() {
|
||||
return cy.request("PUT", "/api/setting/mfa-enforcement", {
|
||||
value: "optional",
|
||||
});
|
||||
}
|
||||
|
||||
function enrollNormalUser() {
|
||||
cy.signInAsNormalUser();
|
||||
return cy
|
||||
.request("POST", "/api/ee/mfa/enroll", { password: normal.password })
|
||||
.then(({ body: { secret } }) =>
|
||||
cy
|
||||
.request("POST", "/api/ee/mfa/enroll/confirm", {
|
||||
code: generateTotpCode(secret, Date.now() / 1000),
|
||||
})
|
||||
.then(({ body: { recovery_codes } }) => ({
|
||||
secret,
|
||||
recoveryCodes: recovery_codes,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function enrollViaUI(): Cypress.Chainable<string> {
|
||||
cy.findByRole("button", {
|
||||
name: "Set up two-factor authentication",
|
||||
}).click();
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText("Confirm your password to begin").type(normal.password);
|
||||
cy.button("Continue").click();
|
||||
});
|
||||
return cy.wait("@enroll").then(({ response }) => {
|
||||
const secret = response?.body.secret;
|
||||
H.modal().within(() => {
|
||||
cy.findByLabelText(
|
||||
"Enter the 6-digit code from the authenticator app",
|
||||
).type(generateTotpCode(secret, Date.now() / 1000));
|
||||
cy.button("Set up authentication").click();
|
||||
cy.findByText("Your recovery codes").should("be.visible");
|
||||
cy.button("Done").click();
|
||||
});
|
||||
return cy.wrap(secret, { log: false });
|
||||
});
|
||||
}
|
||||
|
||||
function signInWithPassword() {
|
||||
cy.signOut();
|
||||
cy.visit("/auth/login");
|
||||
cy.findByLabelText("Email address").type(normal.email);
|
||||
cy.findByLabelText("Password").type(normal.password);
|
||||
cy.button("Sign in").click();
|
||||
}
|
||||
|
||||
function getResetLink(html: string) {
|
||||
const [, anchor] = html.match(/<a (.*)>/) ?? [];
|
||||
const [, href] = String(anchor).match(/href="([^"]+)"/) ?? [];
|
||||
return String(href);
|
||||
}
|
||||
|
||||
function generateTotpCode(secret: string, unixSeconds: number): string {
|
||||
return new OTPAuth.TOTP({
|
||||
secret: OTPAuth.Secret.fromBase32(secret),
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
}).generate({ timestamp: unixSeconds * 1000 });
|
||||
}
|
||||
@@ -218,4 +218,50 @@ describe("scenarios > admin > security center", { tags: "@EE" }, () => {
|
||||
cy.findByTestId("sync-advisories").click();
|
||||
cy.wait("@sync");
|
||||
});
|
||||
|
||||
describe("per-version download link", () => {
|
||||
const DOWNLOADABLE_ADVISORY = {
|
||||
advisory_id: "TEST-DL",
|
||||
severity: "critical",
|
||||
title: "Critical bug with a downloadable fix",
|
||||
description: "A critical issue shipping a per-version JAR fix.",
|
||||
remediation: "Upgrade to the patched release",
|
||||
affected_versions: [{ min: "0.58.0", fixed: "0.59.11" }],
|
||||
download_jar_urls: [
|
||||
{ version: "0.58.11", url: "https://downloads.example.com/58.jar" },
|
||||
{ version: "0.59.11", url: "https://downloads.example.com/59.jar" },
|
||||
],
|
||||
match_status: "active",
|
||||
published_at: "2026-03-24T00:00:00Z",
|
||||
updated_at: "2026-03-24T00:00:00Z",
|
||||
} as const;
|
||||
|
||||
it("stores and returns download_jar_urls through the real backend", () => {
|
||||
H.seedSecurityAdvisories([DOWNLOADABLE_ADVISORY]);
|
||||
cy.request("GET", "/api/ee/security-center").then(({ body }) => {
|
||||
const advisory = body.advisories.find(
|
||||
(a: { advisory_id: string }) => a.advisory_id === "TEST-DL",
|
||||
);
|
||||
expect(advisory.download_jar_urls).to.deep.equal([
|
||||
{ version: "0.58.11", url: "https://downloads.example.com/58.jar" },
|
||||
{ version: "0.59.11", url: "https://downloads.example.com/59.jar" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a single download button for the fix matching the instance major version", () => {
|
||||
H.mockSessionProperty("version", { tag: "v0.59.3" });
|
||||
H.seedSecurityAdvisories([DOWNLOADABLE_ADVISORY]);
|
||||
cy.visit("/admin/security-center");
|
||||
|
||||
cy.findAllByTestId("advisory-card")
|
||||
.first()
|
||||
.within(() => {
|
||||
cy.findByRole("link", { name: /Download v0\.59\.11/ })
|
||||
.should("have.attr", "href", "https://downloads.example.com/59.jar")
|
||||
.and("have.attr", "target", "_blank");
|
||||
cy.findByText("Download v0.58.11").should("not.exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -674,7 +674,6 @@ describe("scenarios > admin > databases > sample database", () => {
|
||||
H.createSegment({
|
||||
name: "Small orders",
|
||||
description: "All orders with a total under $100.",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
|
||||
@@ -243,7 +243,6 @@ describe("issues 55617, 55618", () => {
|
||||
cy.intercept("GET", "/api/segment").as("getSegments");
|
||||
H.createSegment({
|
||||
name: "My segment",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
|
||||
@@ -101,7 +101,6 @@ describe("scenarios > admin > datamodel > segments", () => {
|
||||
H.createSegment({
|
||||
name: SEGMENT_NAME,
|
||||
description: "All orders with a total under $100.",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: 1,
|
||||
@@ -346,7 +345,6 @@ describe("scenarios > admin > datamodel > segments", () => {
|
||||
H.createSegment({
|
||||
name: "Foo",
|
||||
description: "All orders with a total under $100.",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: 1,
|
||||
@@ -403,7 +401,6 @@ describe("scenarios > admin > datamodel > segments", () => {
|
||||
H.createSegment({
|
||||
name: SEGMENT_IN_ORDERS,
|
||||
description: "Hey oh",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: 1,
|
||||
@@ -417,7 +414,6 @@ describe("scenarios > admin > datamodel > segments", () => {
|
||||
H.createSegment({
|
||||
name: SEGMENT_IN_PEOPLE,
|
||||
description: "This is a segment in the PEOPLE table",
|
||||
table_id: PEOPLE_ID,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: 1,
|
||||
|
||||
@@ -655,6 +655,26 @@ describe("Remote Sync", () => {
|
||||
.click();
|
||||
H.collectionTable().findByText(UPDATED_REMOTE_QUESTION_NAME);
|
||||
});
|
||||
|
||||
it("keeps the Embed sharing option available for a question in a read-only synced collection (metabase#72752)", () => {
|
||||
H.copySyncedCollectionFixture();
|
||||
H.commitToRepo();
|
||||
// Enable static embedding instance-wide so the Embed option is offered.
|
||||
H.updateSetting("enable-embedding-static", true);
|
||||
H.configureGitAndPullChanges("read-only");
|
||||
|
||||
cy.visit("/");
|
||||
|
||||
H.navigationSidebar()
|
||||
.findByRole("treeitem", { name: /Synced Collection/ })
|
||||
.click();
|
||||
H.collectionTable().findByText(REMOTE_QUESTION_NAME).click();
|
||||
|
||||
// The Embed option stays available on a read-only synced question; the
|
||||
// Publish button inside the modal is disabled instead (unit-tested).
|
||||
H.openSharingMenu();
|
||||
H.sharingMenu().findByText("Embed").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared tenant collections", () => {
|
||||
|
||||
@@ -636,7 +636,6 @@ describe("issue 24922", () => {
|
||||
const segmentDetails = {
|
||||
name: "OrdersSegment",
|
||||
description: "All orders with a total under $100.",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
@@ -1391,37 +1390,3 @@ describe("issue 53527", () => {
|
||||
H.tableInteractive().findByText("ab").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 48562", () => {
|
||||
const questionDetails = {
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
expressions: {
|
||||
CustomColumn: ["contains", ["field", 10000, null], "abc"],
|
||||
},
|
||||
filter: ["+", 1, ["segment", 10001]],
|
||||
aggregation: [["metric", 10002]],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsNormalUser();
|
||||
});
|
||||
|
||||
it("should not crash when referenced columns, segments, and metrics do not exist (metabase#48562)", () => {
|
||||
H.createQuestion(questionDetails, { visitQuestion: true });
|
||||
H.openNotebook();
|
||||
|
||||
H.getNotebookStep("expression").findByText("CustomColumn").click();
|
||||
H.CustomExpressionEditor.value().should("contain", "[Unknown Field]");
|
||||
H.expressionEditorWidget().button("Cancel").click();
|
||||
|
||||
H.getNotebookStep("filter").findByText("1 + [Unknown Segment]").click();
|
||||
H.CustomExpressionEditor.value().should("contain", "[Unknown Segment]");
|
||||
H.expressionEditorWidget().button("Cancel").click();
|
||||
|
||||
H.getNotebookStep("summarize").findByText("[Unknown Metric]").click();
|
||||
H.CustomExpressionEditor.value().should("contain", "[Unknown Metric]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -305,7 +305,6 @@ describe("issue 55300", () => {
|
||||
|
||||
H.createSegment({
|
||||
name: "now",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
@@ -315,7 +314,6 @@ describe("issue 55300", () => {
|
||||
|
||||
H.createSegment({
|
||||
name: "Count",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
@@ -895,64 +893,6 @@ describe("issue 56602", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 61010", () => {
|
||||
const CUSTOM_COLUMN_NAME = "Foo";
|
||||
const AGGREGATION_NAME = "New count";
|
||||
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsNormalUser();
|
||||
|
||||
H.createQuestion(
|
||||
{
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
expressions: {
|
||||
[CUSTOM_COLUMN_NAME]: ["+", 1, 2],
|
||||
},
|
||||
aggregation: [
|
||||
[
|
||||
"aggregation-options",
|
||||
["+", ["count"], 1],
|
||||
{
|
||||
name: AGGREGATION_NAME,
|
||||
"display-name": AGGREGATION_NAME,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
{ visitQuestion: true },
|
||||
);
|
||||
|
||||
H.openNotebook();
|
||||
});
|
||||
|
||||
it("should not be possible to reference a custom expression in itself (metabase#61010)", () => {
|
||||
H.getNotebookStep("expression").findByText(CUSTOM_COLUMN_NAME).click();
|
||||
H.CustomExpressionEditor.clear().type("[Fo");
|
||||
H.CustomExpressionEditor.completions()
|
||||
.findByText("Foo")
|
||||
.should("not.exist");
|
||||
|
||||
H.CustomExpressionEditor.clear().type("[Foo]");
|
||||
H.popover().findByText("Unknown column: Foo").should("be.visible");
|
||||
});
|
||||
|
||||
it("should not be possible to reference an aggregation in itself(metabase#61010)", () => {
|
||||
H.getNotebookStep("summarize").findByText(AGGREGATION_NAME).click();
|
||||
H.CustomExpressionEditor.clear().type("[New cou");
|
||||
H.CustomExpressionEditor.completions()
|
||||
.findByText("New count")
|
||||
.should("not.exist");
|
||||
|
||||
H.CustomExpressionEditor.clear().type("[New count]");
|
||||
H.popover()
|
||||
.findByText("Unknown Aggregation, Measure or Metric: New count")
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 62987", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
|
||||
@@ -1665,3 +1665,37 @@ describe("issue 63416", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 76056", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.setActionsEnabledForDB(SAMPLE_DB_ID);
|
||||
|
||||
H.createDashboard().then(({ body: dashboard }) => {
|
||||
H.visitDashboard(dashboard.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("the dashcard actions panel should not have redundant horizontal space (metabase#76056)", () => {
|
||||
H.editDashboard();
|
||||
cy.button("Add action").click();
|
||||
cy.button("Close").click();
|
||||
|
||||
H.showDashboardCardActions();
|
||||
|
||||
cy.findByTestId("dashboardcard-actions-panel")
|
||||
.should("be.visible")
|
||||
.then(($panel) => {
|
||||
const panelRect = $panel[0].getBoundingClientRect();
|
||||
const buttonRects = Array.from($panel[0].querySelectorAll("a")).map(
|
||||
(button) => button.getBoundingClientRect(),
|
||||
);
|
||||
const maxRight = Math.max(...buttonRects.map((rect) => rect.right));
|
||||
const minLeft = Math.min(...buttonRects.map((rect) => rect.left));
|
||||
const contentWidth = maxRight - minLeft;
|
||||
|
||||
expect(panelRect.width).to.be.closeTo(contentWidth, 10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -242,11 +242,13 @@ describe("Dashboard > Dashboard Questions", () => {
|
||||
H.entityPickerModal().button("Move").click();
|
||||
|
||||
// we shouldn't be making 20 requests here
|
||||
cy.wait(new Array(20).fill("@updateCard"));
|
||||
// their staggered tail can land past cy.wait's default 5s under CPU load, so allow more time
|
||||
cy.wait(new Array(20).fill("@updateCard"), { timeout: 30000 });
|
||||
|
||||
H.undoToast().findByText("Moved 20 questions");
|
||||
|
||||
H.visitDashboard(S.ORDERS_DASHBOARD_ID);
|
||||
// 20+ dashcard queries stagger behind the concurrency limit; allow their tail past the 5s default
|
||||
H.visitDashboard(S.ORDERS_DASHBOARD_ID, { dashcardTimeout: 30000 });
|
||||
|
||||
new Array(20).fill("slowbro").forEach((_, i) => {
|
||||
H.dashboardCards().findByText(`Question ${i + 1}`);
|
||||
@@ -269,7 +271,7 @@ describe("Dashboard > Dashboard Questions", () => {
|
||||
|
||||
cy.wait(["@updateCard", "@updateCard"]);
|
||||
cy.findByTestId("error-boundary").should("not.exist");
|
||||
H.visitDashboard(S.ORDERS_DASHBOARD_ID);
|
||||
H.visitDashboard(S.ORDERS_DASHBOARD_ID, { dashcardTimeout: 30000 });
|
||||
H.dashboardCards().findByText("Orders");
|
||||
H.dashboardCards().findByText("Orders, Count");
|
||||
});
|
||||
|
||||
@@ -841,7 +841,6 @@ describe("issue 31697", () => {
|
||||
const segmentDetails = {
|
||||
name: "Orders segment",
|
||||
description: "All orders with a total under $100.",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
|
||||
@@ -404,7 +404,6 @@ describe(
|
||||
cy.log("create segment on Products table");
|
||||
H.createSegment({
|
||||
name: "Expensive Products",
|
||||
table_id: PRODUCTS_ID,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: SAMPLE_DB_ID,
|
||||
@@ -441,7 +440,6 @@ describe(
|
||||
cy.log("create segment based on segment");
|
||||
H.createSegment({
|
||||
name: "High Value Recent Orders",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: SAMPLE_DB_ID,
|
||||
@@ -475,7 +473,6 @@ describe(
|
||||
cy.log("create Segment A");
|
||||
H.createSegment({
|
||||
name: "Segment A",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: SAMPLE_DB_ID,
|
||||
@@ -488,7 +485,6 @@ describe(
|
||||
cy.log("create Segment B that depends on A");
|
||||
H.createSegment({
|
||||
name: "Segment B",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: SAMPLE_DB_ID,
|
||||
@@ -739,7 +735,6 @@ function createTestSegment(
|
||||
H.createSegment({
|
||||
name,
|
||||
description,
|
||||
table_id: tableId,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: SAMPLE_DB_ID,
|
||||
|
||||
@@ -404,6 +404,11 @@ describe(
|
||||
createTestTables();
|
||||
createSourceQuestion("Graph question").as("question");
|
||||
|
||||
// The replacement modal fetches the dependents of the source table
|
||||
// exactly once when it opens; wait for the async dependency backfill
|
||||
// so the question's dependency row exists by then.
|
||||
H.waitForBackfillComplete();
|
||||
|
||||
getTableId(SOURCE_TABLE).then((sourceTableId) => {
|
||||
cy.visit(`/data-studio/dependencies?id=${sourceTableId}&type=table`);
|
||||
});
|
||||
@@ -821,6 +826,12 @@ function getTableId(tableName: string) {
|
||||
}
|
||||
|
||||
function openReplacementModal(sourceTableLabel: string) {
|
||||
// The modal fetches the dependents of the source table exactly once when it
|
||||
// opens, and the card -> table dependency rows are written by an async
|
||||
// backfill job. Wait for the backfill so the modal doesn't race it and show
|
||||
// "Nothing uses this data source" for a table that does have dependents.
|
||||
H.waitForBackfillComplete();
|
||||
|
||||
H.DataModel.visitDataStudio();
|
||||
|
||||
H.DataModel.TablePicker.getDatabase("Writable Postgres12").click();
|
||||
@@ -1042,7 +1053,6 @@ function createHighAmountSegment() {
|
||||
(amountFieldId) =>
|
||||
H.createSegment({
|
||||
name: "High amount",
|
||||
table_id: sourceTableId,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: WRITABLE_DB_ID,
|
||||
@@ -1062,7 +1072,6 @@ function createSourceTotalAmountMeasure() {
|
||||
(amountFieldId) =>
|
||||
H.createMeasure({
|
||||
name: "Total amount",
|
||||
table_id: sourceTableId,
|
||||
definition: {
|
||||
"source-table": sourceTableId,
|
||||
aggregation: [["sum", ["field", amountFieldId, null]]],
|
||||
|
||||
@@ -529,7 +529,6 @@ function createTestMeasure(
|
||||
H.createMeasure({
|
||||
name,
|
||||
description,
|
||||
table_id: tableId,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: SAMPLE_DB_ID,
|
||||
|
||||
@@ -140,7 +140,6 @@ function createTestMeasure(opts: { name?: string; description?: string } = {}) {
|
||||
H.createMeasure({
|
||||
name,
|
||||
description,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: SAMPLE_DB_ID,
|
||||
|
||||
@@ -107,7 +107,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("should create a measure based on a segment", () => {
|
||||
H.createSegment({
|
||||
name: "TotalSegment",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
filter: ["<", ["field", ORDERS.TOTAL, null], 100],
|
||||
@@ -129,7 +128,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("should create a measure based on another measure with an identity expression", () => {
|
||||
H.createMeasure({
|
||||
name: "TotalMeasure",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
@@ -151,7 +149,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("should create a measure based on another measure", () => {
|
||||
H.createMeasure({
|
||||
name: "TotalMeasure",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
@@ -173,7 +170,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("should create a measure based on another measure with an identity expression", () => {
|
||||
H.createMeasure({
|
||||
name: "TotalMeasure",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
@@ -254,7 +250,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("should be possible to create measures with filters like CountIf based on segments", () => {
|
||||
H.createSegment({
|
||||
name: "LargeTotal",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: SAMPLE_DB_ID,
|
||||
@@ -280,7 +275,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("should be possible to create measures with filters like based on segments that are nested", () => {
|
||||
H.createSegment({
|
||||
name: "LargeTotal",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: SAMPLE_DB_ID,
|
||||
@@ -292,7 +286,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
}).then(({ body: segment }) => {
|
||||
H.createSegment({
|
||||
name: "NestedSegment",
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: SAMPLE_DB_ID,
|
||||
@@ -323,7 +316,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("should be possible to offset a measure in a query", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
@@ -349,7 +341,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("should be possible to order by an aggregation using a measure directly", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
@@ -375,7 +366,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("should be possible to order by an aggregation using a custom expression based on a measure", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
@@ -409,7 +399,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("should be possible to use results of a measure in follow up stages", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
@@ -449,7 +438,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("should be possible to join on a measure in a follow up stage", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
@@ -478,7 +466,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("should be possible to join on a measure in a follow up stage with a custom expression", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
@@ -512,7 +499,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("should be possible to rename a measure without breaking queries that reference it", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
@@ -533,7 +519,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("should be possible to rename an aggregation expression based on a measure without breaking it", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
@@ -556,7 +541,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("changing the top-level aggregation expression in a measure might break queries that reference it in follow up stages", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
@@ -613,7 +597,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("should be possible to use a measure in a pivot table", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
@@ -660,7 +643,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("should be possible to use measure results in a saved question as source for a follow up question", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
@@ -714,7 +696,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("should be possible to use measure results in a saved question as source for a follow up model", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["sum", ["field", ORDERS.TOTAL, null]]],
|
||||
@@ -769,7 +750,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("should be possible x-ray a question containing a measure", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
@@ -803,7 +783,6 @@ describe("scenarios > data studio > measures > queries", () => {
|
||||
it("should be possible x-ray a model containing a measure", () => {
|
||||
H.createMeasure({
|
||||
name: MEASURE_NAME,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
|
||||
@@ -144,7 +144,6 @@ function createTestSegment(opts: { name?: string; description?: string } = {}) {
|
||||
H.createSegment({
|
||||
name,
|
||||
description,
|
||||
table_id: ORDERS_ID,
|
||||
definition: {
|
||||
type: "query",
|
||||
database: SAMPLE_DB_ID,
|
||||
|
||||