Compare commits
48 Commits
testing-ba
...
split-api-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64fb24e355 | ||
|
|
74b08a51c8 | ||
|
|
244700d893 | ||
|
|
2a3f535c39 | ||
|
|
eebccaad10 | ||
|
|
3b936768f0 | ||
|
|
1d56ea8657 | ||
|
|
4981a05a19 | ||
|
|
bd8bab1d36 | ||
|
|
56117f5bb8 | ||
|
|
9b9b40a5f9 | ||
|
|
24e36a9f0e | ||
|
|
c2d55422d1 | ||
|
|
c0fe1712f6 | ||
|
|
24b402cf95 | ||
|
|
ca89dc06f1 | ||
|
|
698956f32f | ||
|
|
4b10b24deb | ||
|
|
0635ba585d | ||
|
|
e9d98d62c3 | ||
|
|
a6b5e09200 | ||
|
|
fa3af4772e | ||
|
|
8350e6cc4a | ||
|
|
2f3d32a2ba | ||
|
|
c9da277bd3 | ||
|
|
d225a5f8d0 | ||
|
|
16d281da2a | ||
|
|
7592d39e42 | ||
|
|
9e6e08bffb | ||
|
|
9bdf46f3d7 | ||
|
|
814b57b74a | ||
|
|
570a0e18c8 | ||
|
|
bfa63e2a92 | ||
|
|
ec9e8c40f0 | ||
|
|
518ca39d56 | ||
|
|
0fc5582ae2 | ||
|
|
b750f8d8fa | ||
|
|
fc7d75b01f | ||
|
|
af266d1426 | ||
|
|
660b7ac2db | ||
|
|
cc136d9725 | ||
|
|
aee69e552a | ||
|
|
4dc84c1615 | ||
|
|
fb00ed06ef | ||
|
|
7675914513 | ||
|
|
28160265ec | ||
|
|
3ec36759e7 | ||
|
|
d8a19a330a |
@@ -523,7 +523,6 @@
|
||||
metabase.test.data.interface/defdataset clojure.core/def
|
||||
metabase.test.data.interface/defdataset-edn clojure.core/def
|
||||
metabase.test.embedded-postgres.core/with-system clojure.core/let
|
||||
metabase.test.gentest/defgentest clojure.test/deftest
|
||||
metabase.test/defdataset clojure.core/def
|
||||
metabase.test/let-url clojure.core/let
|
||||
metabase.test/with-actions clojure.core/let
|
||||
@@ -682,7 +681,6 @@
|
||||
metabase.test.data/dataset hooks.metabase.test.data/dataset
|
||||
metabase.test.data/mbql-query hooks.metabase.test.data/mbql-query
|
||||
metabase.test.data/run-mbql-query hooks.metabase.test.data/mbql-query
|
||||
metabase.test.gentest/iterate hooks.metabase.test.gentest/iterate*
|
||||
metabase.test.util.async/with-open-channels hooks.common/let-with-optional-value-for-last-binding
|
||||
metabase.test.util.generators.jvm/with-random-cards hooks.metabase.test.util.generators.jvm/with-random-cards
|
||||
metabase.test.util.log/with-log-level hooks.common/with-ignored-first-arg
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
;; to defend in your PR.
|
||||
;; :all is the vector-less ignore form, which suppresses every linter on the next form.
|
||||
{:ignore-counts {:aliased-namespace-symbol 3
|
||||
:all 50
|
||||
:all 49
|
||||
:case-symbol-test 1
|
||||
:clojure-lsp/unused-public-var 9
|
||||
:def-fn 1
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
(ns hooks.metabase.test.gentest
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]))
|
||||
|
||||
(defn iterate*
|
||||
"(iterate {:as limit-spec} [s1 val1 & more] body-form body-form...)"
|
||||
[{{:keys [children] :as node} :node :as orig}]
|
||||
(let [[token limit-spec bindings & body-nodes] children
|
||||
node* (-> (hooks/list-node
|
||||
(list token limit-spec
|
||||
(hooks/list-node (list* (hooks/token-node 'let) bindings
|
||||
body-nodes))))
|
||||
(with-meta (meta node)))]
|
||||
(assoc orig :node node*)))
|
||||
8
.github/actionlint.yaml
vendored
8
.github/actionlint.yaml
vendored
@@ -3,3 +3,11 @@ self-hosted-runner:
|
||||
# selected through `vars.DEFAULT_RUNNER_KEY` and is not checked.
|
||||
labels:
|
||||
- arm-ubuntu-24-2core
|
||||
|
||||
paths:
|
||||
# GitHub added parallel steps (`parallel`, `background`, `wait`, `wait-all`,
|
||||
# `cancel`) in June 2026, and actionlint 1.7.12 does not know the schema yet:
|
||||
# https://github.blog/changelog/2026-06-25-actions-steps-can-now-be-run-in-parallel/
|
||||
.github/workflows/lint.yml:
|
||||
ignore:
|
||||
- 'step must run script with "run" section or run action with "uses" section'
|
||||
|
||||
8
.github/actions/prepare-frontend/action.yml
vendored
8
.github/actions/prepare-frontend/action.yml
vendored
@@ -53,7 +53,13 @@ runs:
|
||||
# If a job wants to patch packages, it does so explicitly in the CI. This setting is opt-out.
|
||||
# For example, we never want to cache node_modules with patches already applied so cache-generator.yml skips that step.
|
||||
- name: Clean install
|
||||
run: bun install --frozen-lockfile
|
||||
# Remove the restored node_modules before installing. The os-week cache can
|
||||
# carry a stale tree (for example a different React major), and
|
||||
# `--frozen-lockfile` alone does not prune it. Rebuilding from the cached bun
|
||||
# store gives an exact, lockfile-correct node_modules with no extra download.
|
||||
run: |
|
||||
rm -rf node_modules
|
||||
bun install --frozen-lockfile
|
||||
shell: bash
|
||||
- name: Patch frontend dependencies
|
||||
if: inputs.apply-patches == 'true'
|
||||
|
||||
40
.github/actions/upload-test-results/action.yml
vendored
40
.github/actions/upload-test-results/action.yml
vendored
@@ -1,40 +0,0 @@
|
||||
name: Upload Test Results to s3
|
||||
description: Uploads test results files to s3
|
||||
|
||||
inputs:
|
||||
input-path:
|
||||
required: true
|
||||
output-name:
|
||||
required: true
|
||||
default: test
|
||||
role-to-assume:
|
||||
required: true
|
||||
aws-region:
|
||||
required: true
|
||||
default: us-east-1
|
||||
bucket:
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885
|
||||
with:
|
||||
role-to-assume: ${{ inputs.role-to-assume }}
|
||||
aws-region: ${{ inputs.aws-region }}
|
||||
- name: zip test results
|
||||
env:
|
||||
INPUT_DIR: ${{ inputs.input-path }}
|
||||
OUTPUT_FILE: ${{ inputs.output-name }}
|
||||
shell: bash
|
||||
run: | # sh
|
||||
zip ${OUTPUT_FILE}.zip $INPUT_DIR/*.xml
|
||||
- name: Upload test results to S3
|
||||
env:
|
||||
BUCKET: ${{ inputs.bucket }}
|
||||
OUTPUT_FILE: ${{ inputs.output-name }}
|
||||
shell: bash
|
||||
run: | # sh
|
||||
DATE=$(date '+%Y-%m-%d')
|
||||
aws s3 cp ${OUTPUT_FILE}.zip s3://$BUCKET/$DATE/$GITHUB_RUN_ID/$GITHUB_RUN_ATTEMPT/
|
||||
22
.github/actions/verify-membership/action.yml
vendored
22
.github/actions/verify-membership/action.yml
vendored
@@ -1,22 +0,0 @@
|
||||
name: 'Verify Organization Membership'
|
||||
description: 'Check if a user is a member of a GitHub organization'
|
||||
inputs:
|
||||
github_token:
|
||||
description: 'Token with access to the organization'
|
||||
required: true
|
||||
organization_name:
|
||||
description: 'Organization name'
|
||||
required: true
|
||||
username:
|
||||
description: 'Username'
|
||||
required: true
|
||||
outputs:
|
||||
is_member:
|
||||
value: ${{ steps.verify-membership.outputs.is_member }}
|
||||
description: "Is the user a member of the organization"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- run: bash ${{ github.action_path }}/verify_membership.sh ${{ inputs.github_token }} ${{ inputs.organization_name }} ${{ inputs.username }}
|
||||
id: verify-membership
|
||||
shell: bash
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
TOKEN=$1
|
||||
ORG_NAME=$2
|
||||
USERNAME=$3
|
||||
|
||||
response=$(curl -L -o /dev/null -w "%{http_code}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"https://api.github.com/orgs/$ORG_NAME/members/$USERNAME")
|
||||
|
||||
# Github returns an empty response with 204 status if user is a member and 404 status otherwise
|
||||
# See: https://docs.github.com/en/rest/orgs/members?apiVersion=2022-11-28#check-organization-membership-for-a-user
|
||||
if [ "$response" == "204" ]; then
|
||||
echo "The user $USERNAME is a member of $ORG_NAME"
|
||||
echo "is_member=true" >> $GITHUB_OUTPUT
|
||||
elif [ "$response" == "404" ]; then
|
||||
echo "The user $USERNAME is not a member of $ORG_NAME"
|
||||
echo "is_member=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Failed to determine the membership status for $USERNAME in $ORG_NAME"
|
||||
echo "is_member=false" >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
fi
|
||||
20
.github/file-paths.yaml
vendored
20
.github/file-paths.yaml
vendored
@@ -106,13 +106,6 @@ backend_ci: &backend_ci
|
||||
- ".github/workflows/backend.yml"
|
||||
- ".github/workflows/drivers.yml"
|
||||
|
||||
backend_presto_kerberos:
|
||||
- "**/presto_jdbc/**"
|
||||
- "**/presto-jdbc/**" # the driver module has a hyphen. underscores are classpaths
|
||||
- "**/presto_jdbc.clj"
|
||||
- "**/presto_jdbc_test.clj"
|
||||
- ".github/workflows/presto-kerberos-integration-test.yml"
|
||||
|
||||
backend_sources: &backend_sources
|
||||
- *shared_sources
|
||||
- "**deps.edn"
|
||||
@@ -183,7 +176,7 @@ embedding_sdk_host_sample_apps:
|
||||
- "e2e/test-host-app/**/*.cy.*.{js,ts}"
|
||||
|
||||
snowplow: &snowplow
|
||||
- ".github/workflows/snowplow.yml"
|
||||
- ".github/workflows/lint.yml"
|
||||
- "snowplow/**"
|
||||
- "bin/lint-snowplow-schemas.bb"
|
||||
- "bin/lint-snowplow-schemas-test.bb"
|
||||
@@ -212,7 +205,7 @@ yaml:
|
||||
- "**/*.yaml"
|
||||
|
||||
codeql:
|
||||
- ".github/workflows/codeql.yml"
|
||||
- ".github/workflows/lint.yml"
|
||||
- "frontend/src/**"
|
||||
- "enterprise/frontend/src/**"
|
||||
|
||||
@@ -220,14 +213,9 @@ i18n:
|
||||
- *default
|
||||
- ".github/actions/prepare-frontend/**"
|
||||
- ".github/actions/prepare-backend/**"
|
||||
- ".github/workflows/i18n.yml"
|
||||
- ".github/workflows/lint.yml"
|
||||
- *sources
|
||||
|
||||
visualizations:
|
||||
- "frontend/src/metabase/visualizations/**"
|
||||
- "frontend/src/metabase/static-viz/**"
|
||||
- "src/metabase/pulse/**"
|
||||
|
||||
cljs:
|
||||
- *shared_sources
|
||||
- *shared_specs
|
||||
@@ -239,7 +227,7 @@ serialization_baseline:
|
||||
- "test_resources/serialization_baseline/**"
|
||||
|
||||
openapi:
|
||||
- ".github/workflows/openapi.yml"
|
||||
- ".github/workflows/lint.yml"
|
||||
- "src/metabase/api/**"
|
||||
- "src/metabase/api_routes/**"
|
||||
- "src/metabase/*_rest/api.clj"
|
||||
|
||||
66
.github/scripts/fix-yarn-lock-aliases.ts
vendored
Normal file
66
.github/scripts/fix-yarn-lock-aliases.ts
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Renames aliased dependency keys in a bun-generated yarn.lock.
|
||||
*
|
||||
* Yarn names an alias entry after the alias, for example
|
||||
* `"typescript7@npm:typescript@7.0.2"`. Bun names it after the aliased package
|
||||
* instead, for example `"typescript@npm:typescript@7.0.2"`. Snyk follows the
|
||||
* yarn convention and fails to resolve the dependency.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
async function main() {
|
||||
const packageJsonPath = `${process.cwd()}/package.json`;
|
||||
const lockPath = `${process.cwd()}/yarn.lock`;
|
||||
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
||||
|
||||
const dependencies = Object.entries<string>(
|
||||
Object.assign(
|
||||
{},
|
||||
packageJson.dependencies,
|
||||
packageJson.devDependencies,
|
||||
packageJson.optionalDependencies,
|
||||
),
|
||||
);
|
||||
|
||||
let lock = await readFile(lockPath, "utf8");
|
||||
const renamedKeys: string[] = [];
|
||||
|
||||
for (const [alias, specifier] of dependencies) {
|
||||
if (!specifier.startsWith("npm:")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const aliasedPackage = specifier
|
||||
.slice("npm:".length)
|
||||
.replace(/@[^@]*$/, "");
|
||||
|
||||
if (aliasedPackage === alias) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bunKey = `"${aliasedPackage}@${specifier}"`;
|
||||
const yarnKey = `"${alias}@${specifier}"`;
|
||||
|
||||
if (lock.includes(bunKey)) {
|
||||
lock = lock.replaceAll(bunKey, yarnKey);
|
||||
renamedKeys.push(`${bunKey} -> ${yarnKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (renamedKeys.length > 0) {
|
||||
await writeFile(lockPath, lock);
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
renamedKeys.length > 0
|
||||
? `Renamed alias keys in yarn.lock:\n${renamedKeys.join("\n")}\n`
|
||||
: "No alias keys to rename in yarn.lock\n",
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`Failed to rename alias keys in yarn.lock: ${error}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
84
.github/scripts/get-issue-data.js
vendored
84
.github/scripts/get-issue-data.js
vendored
@@ -1,84 +0,0 @@
|
||||
const githubSlackMap = require("../../release/github-slack-map.json");
|
||||
|
||||
async function getIssueData({ context, github, manualIssueNumber }) {
|
||||
let issueNumber;
|
||||
let issueData;
|
||||
let prReviews = [];
|
||||
const triggerType = context.eventName;
|
||||
|
||||
if (context.eventName === "issues") {
|
||||
issueNumber = context.payload.issue.number;
|
||||
issueData = context.payload.issue;
|
||||
} else if (context.eventName === "workflow_dispatch") {
|
||||
issueNumber = parseInt(manualIssueNumber);
|
||||
const { data } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
issueData = data;
|
||||
} else if (context.eventName === "pull_request_review") {
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const { data: prData } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
|
||||
// Extract issue number from branch name (e.g., "claude-fix/issue-12345-some-title")
|
||||
const branchMatch = prData.head.ref.match(/claude-fix\/issue-(\d+)/);
|
||||
if (!branchMatch) {
|
||||
throw new Error(
|
||||
`Could not extract issue number from branch name: ${prData.head.ref}`,
|
||||
);
|
||||
}
|
||||
|
||||
issueNumber = parseInt(branchMatch[1]);
|
||||
|
||||
const { data } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
issueData = data;
|
||||
|
||||
const { data: reviews } = await github.rest.pulls.listReviews({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
|
||||
prReviews = reviews.filter(
|
||||
(review) =>
|
||||
review.state === "CHANGES_REQUESTED" &&
|
||||
isMetabaseMember(review.user.login),
|
||||
);
|
||||
}
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
|
||||
const employeeComments = comments.filter((comment) =>
|
||||
isMetabaseMember(comment.user.login),
|
||||
);
|
||||
|
||||
return {
|
||||
issueNumber,
|
||||
issueData,
|
||||
employeeComments,
|
||||
prReviews,
|
||||
triggerType,
|
||||
isIssueAuthorEmployee: isMetabaseMember(issueData.user.login),
|
||||
};
|
||||
}
|
||||
|
||||
function isMetabaseMember(username) {
|
||||
return username in githubSlackMap;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getIssueData,
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
#! /usr/bin/env bash
|
||||
# runs one or more Metabase test(s) against a Kerberized Presto instance
|
||||
set -eo pipefail
|
||||
|
||||
# Need Java commands on $PATH, which apparently is not yet the case
|
||||
export JAVA_HOME="/usr/lib/jvm/msopenjdk-current"
|
||||
export PATH="$PATH:$JAVA_HOME/bin"
|
||||
|
||||
# ensure java commmand is available
|
||||
which java
|
||||
|
||||
# install clojure version needed for Metabase
|
||||
curl -O https://download.clojure.org/install/linux-install-1.12.0.1488.sh
|
||||
chmod +x linux-install-1.12.0.1488.sh
|
||||
./linux-install-1.12.0.1488.sh
|
||||
|
||||
RESOURCES_DIR=/app/source/resources
|
||||
|
||||
# ensure the expected files are in place, in the resources dir
|
||||
if [ ! -f "$RESOURCES_DIR/ssl_keystore.jks" ]; then
|
||||
echo "$RESOURCES_DIR/ssl_keystore.jks does not exist; cannot run test" >&2
|
||||
exit 11
|
||||
fi
|
||||
|
||||
if [ ! -f "$RESOURCES_DIR/krb5.conf" ]; then
|
||||
echo "$RESOURCES_DIR/krb5.conf does not exist; cannot run test" >&2
|
||||
exit 12
|
||||
fi
|
||||
|
||||
if [ ! -f "$RESOURCES_DIR/client.keytab" ]; then
|
||||
echo "$RESOURCES_DIR/client.keytab does not exist; cannot run test" >&2
|
||||
exit 13
|
||||
fi
|
||||
|
||||
# Copy the JDK cacerts file to our resources
|
||||
cp $JAVA_HOME/lib/security/cacerts $RESOURCES_DIR/cacerts-with-presto-ca.jks
|
||||
|
||||
# Capture the Presto server self signed CA in PEM format
|
||||
openssl s_client -showcerts -connect presto-kerberos:7778 </dev/null \
|
||||
| openssl x509 -outform PEM >$RESOURCES_DIR/presto-ssl-root-ca.pem
|
||||
|
||||
# Convert the Presto server self signed CA to DER format
|
||||
openssl x509 -outform der -in $RESOURCES_DIR/presto-ssl-root-ca.pem -out $RESOURCES_DIR/presto-ssl-root-ca.der
|
||||
|
||||
# Add Presto's self signed CA to the truststore
|
||||
keytool -noprompt -import -alias presto-kerberos -keystore $RESOURCES_DIR/cacerts-with-presto-ca.jks \
|
||||
-storepass changeit -file $RESOURCES_DIR/presto-ssl-root-ca.der -trustcacerts
|
||||
|
||||
# Set up the environment variables pointing to all of this, and run some tests
|
||||
DRIVERS=presto-jdbc \
|
||||
MB_ENABLE_PRESTO_JDBC_DRIVER=true \
|
||||
MB_PRESTO_JDBC_TEST_HOST=presto-kerberos \
|
||||
MB_PRESTO_JDBC_TEST_PORT=7778 \
|
||||
MB_PRESTO_JDBC_TEST_SSL=true \
|
||||
MB_PRESTO_JDBC_TEST_SSL_KEYSTORE_PATH=$RESOURCES_DIR/ssl_keystore.jks \
|
||||
MB_PRESTO_JDBC_TEST_SSL_KEYSTORE_PASSWORD=presto \
|
||||
MB_PRESTO_JDBC_TEST_SSL_TRUSTSTORE_PATH=$RESOURCES_DIR/cacerts-with-presto-ca.jks \
|
||||
MB_PRESTO_JDBC_TEST_SSL_TRUSTSTORE_PASSWORD=changeit \
|
||||
MB_PRESTO_JDBC_TEST_KERBEROS=true \
|
||||
MB_PRESTO_JDBC_TEST_USER=bob@EXAMPLE.COM \
|
||||
MB_PRESTO_JDBC_TEST_KERBEROS_PRINCIPAL=bob@EXAMPLE.COM \
|
||||
MB_PRESTO_JDBC_TEST_KERBEROS_REMOTE_SERVICE_NAME=HTTP \
|
||||
MB_PRESTO_JDBC_TEST_KERBEROS_KEYTAB_PATH=$RESOURCES_DIR/client.keytab \
|
||||
MB_PRESTO_JDBC_TEST_KERBEROS_CONFIG_PATH=$RESOURCES_DIR/krb5.conf \
|
||||
clojure -X:dev:test:drivers:drivers-dev :only metabase.driver.presto-jdbc-test
|
||||
3
.github/team.json
vendored
3
.github/team.json
vendored
@@ -65,7 +65,8 @@
|
||||
"piranha",
|
||||
"crisptrutski",
|
||||
"undrfined",
|
||||
"andreis"
|
||||
"andreis",
|
||||
"prascuna"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
44
.github/workflows/actionlint.yml
vendored
44
.github/workflows/actionlint.yml
vendored
@@ -1,44 +0,0 @@
|
||||
name: Actionlint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'release-**'
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
name: Lint Github Actions Workflows
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Get changed Github actions
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
filters: .github/file-paths.yaml
|
||||
list-files: shell
|
||||
- name: Setup actionlint
|
||||
if: steps.changes.outputs.actionlint == 'true'
|
||||
id: download
|
||||
shell: bash
|
||||
run: |
|
||||
if command -v actionlint; then
|
||||
echo "executable=actionlint" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# script sets executable output, for details see: https://github.com/rhysd/actionlint/blob/main/docs/usage.md#use-actionlint-on-github-actions
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.12/scripts/download-actionlint.bash)
|
||||
fi
|
||||
- name: Run actionlint
|
||||
if: steps.changes.outputs.actionlint == 'true'
|
||||
run: ${{ steps.download.outputs.executable }} --color --verbose ${{ steps.changes.outputs.actionlint_files }}
|
||||
27
.github/workflows/app-db.yml
vendored
27
.github/workflows/app-db.yml
vendored
@@ -125,15 +125,6 @@ jobs:
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
be-tests-mysql:
|
||||
if: ${{ !inputs.skip }}
|
||||
@@ -260,15 +251,6 @@ jobs:
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
be-tests-postgres:
|
||||
if: ${{ !inputs.skip }}
|
||||
@@ -370,15 +352,6 @@ jobs:
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
app-db-tests-result:
|
||||
needs:
|
||||
|
||||
71
.github/workflows/auto-backport.yml
vendored
71
.github/workflows/auto-backport.yml
vendored
@@ -99,6 +99,36 @@ jobs:
|
||||
)
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
steps:
|
||||
- name: "!! double-backport and triple-backport label deprecation warning !!"
|
||||
if:
|
||||
|
|
||||
github.event.pull_request.merged == true && (
|
||||
github.event.action == 'closed' && (
|
||||
contains(github.event.pull_request.labels.*.name, 'double-backport') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'triple-backport')
|
||||
) ||
|
||||
github.event.action == 'labeled' && (
|
||||
github.event.label.name == 'double-backport' ||
|
||||
github.event.label.name == 'triple-backport'
|
||||
)
|
||||
)
|
||||
env:
|
||||
REPO: ${{ github.repository }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
GH_TOKEN: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
run: |
|
||||
msg="🚨🚨 Backport failed because \`double-backport\` and \`triple-backport\` labels are **deprecated**! Use the \`backport\` label to port changes to all supported releases instead. 🚨🚨"
|
||||
|
||||
gh api \
|
||||
--silent \
|
||||
--method POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2026-03-10" \
|
||||
/repos/"$REPO"/issues/"$PR"/comments \
|
||||
-f "body=$msg"
|
||||
|
||||
echo "::error::$msg"
|
||||
exit 1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -110,29 +140,6 @@ jobs:
|
||||
pull-request-number: ${{ github.event.pull_request.number }}
|
||||
base-ref: ${{ github.event.pull_request.base.ref }}
|
||||
|
||||
- name: how many backports?
|
||||
id: how-many-backports
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: | # js
|
||||
const labels = [
|
||||
...${{ toJson(github.event.pull_request.labels) }},
|
||||
{ name:"${{ github.event.label.name }}" }
|
||||
];
|
||||
|
||||
const backportLabelNames = labels.filter(label => label.name.includes('backport')).map(label => label.name);
|
||||
|
||||
let result = 1;
|
||||
|
||||
if (backportLabelNames.includes("triple-backport")) {
|
||||
result = 3;
|
||||
} else if (backportLabelNames.includes("double-backport")) {
|
||||
result = 2;
|
||||
}
|
||||
|
||||
console.log("backports", result);
|
||||
return result;
|
||||
|
||||
- name: get release versions
|
||||
id: get-latest-release-version
|
||||
uses: actions/github-script@v9
|
||||
@@ -157,21 +164,3 @@ jobs:
|
||||
backport-commit: ${{ steps.find_commit.outputs.commit }}
|
||||
github-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
github-token-2: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Double Backport
|
||||
if: ${{ always() && fromJson(steps.how-many-backports.outputs.result) >= 2 }}
|
||||
uses: ./.github/actions/create-backport
|
||||
with:
|
||||
target-version: ${{ fromJson(steps.get-latest-release-version.outputs.result).secondLatest }}
|
||||
backport-commit: ${{ steps.find_commit.outputs.commit }}
|
||||
github-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
github-token-2: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Triple Backport
|
||||
if: ${{ always() && fromJson(steps.how-many-backports.outputs.result) >= 3 }}
|
||||
uses: ./.github/actions/create-backport
|
||||
with:
|
||||
target-version: ${{ fromJson(steps.get-latest-release-version.outputs.result).thirdLatest }}
|
||||
backport-commit: ${{ steps.find_commit.outputs.commit }}
|
||||
github-token: ${{ secrets.METABASE_AUTOMATION_USER_TOKEN }}
|
||||
github-token-2: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
10
.github/workflows/backend.yml
vendored
10
.github/workflows/backend.yml
vendored
@@ -281,16 +281,6 @@ jobs:
|
||||
test-suite: be-tests-java-${{ matrix.java-version }}-${{ matrix.job.test-group-name }}
|
||||
enforce-quarantine: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
|
||||
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: be-tests-java-${{ matrix.java-version }}-${{ matrix.job.edition }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
- name: Publish Test Report (JUnit)
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
if: failure()
|
||||
|
||||
@@ -5,10 +5,10 @@ on:
|
||||
|
||||
jobs:
|
||||
check-source-branch:
|
||||
if: github.head_ref == 'master'
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
steps:
|
||||
- name: Block master branch PRs
|
||||
if: github.head_ref == 'master'
|
||||
run: |
|
||||
echo "PRs from \`master\` branches are not allowed, please use a new branch name for your PR."
|
||||
exit 1
|
||||
|
||||
273
.github/workflows/build-for-release.yml
vendored
273
.github/workflows/build-for-release.yml
vendored
@@ -1,273 +0,0 @@
|
||||
# Builds a fresh release JAR from scratch at a given commit.
|
||||
#
|
||||
# You should almost never have to use this. The normal release flow relies on a
|
||||
# commit that has already been built and tested on its branch — see
|
||||
# `tag-for-release.yml`, which tags that existing JAR. Building from scratch
|
||||
# here is only for exceptional cases.
|
||||
name: Release 0 - Build Release Artifact
|
||||
run-name: Build ${{ inputs.version }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Metabase version (e.g. v0.46.3)'
|
||||
type: string
|
||||
required: true
|
||||
commit:
|
||||
description: 'A full-length commit SHA-1 hash'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
start-message:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: |
|
||||
release
|
||||
.github
|
||||
- name: Prepare release scripts
|
||||
uses: ./.github/actions/prepare-release-scripts
|
||||
- name: Send build start message
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
SLACK_RELEASE_CHANNEL: ${{ vars.SLACK_RELEASE_CHANNEL }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
with:
|
||||
script: | # js
|
||||
const { sendPreReleaseMessage } = require('${{ github.workspace }}/release/dist/index.cjs');
|
||||
|
||||
await sendPreReleaseMessage({
|
||||
github,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
version: '${{ inputs.version }}',
|
||||
runId: '${{ github.run_id }}',
|
||||
releaseSha: '${{ inputs.commit }}',
|
||||
userName: '${{ github.actor }}',
|
||||
channelName: '${{ vars.SLACK_RELEASE_CHANNEL }}',
|
||||
}).catch(console.error);
|
||||
|
||||
trigger-milestone-check:
|
||||
# Posts a Slack FYI about issues that still need review for this release.
|
||||
# Informational only — nothing depends on it, so it never blocks the release.
|
||||
needs: [check-version, check-commit]
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
uses: ./.github/workflows/release-milestone-check.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
commit: ${{ inputs.commit }}
|
||||
|
||||
check-version:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
ee: ${{ fromJson(steps.canonical_version.outputs.result).ee }}
|
||||
oss: ${{ fromJson(steps.canonical_version.outputs.result).oss }}
|
||||
steps:
|
||||
- name: Fail early on the incorrect version format
|
||||
if: ${{ !(startsWith(inputs.version,'v0.') || startsWith(inputs.version,'v1.')) }}
|
||||
run: |
|
||||
echo "The version format is invalid!"
|
||||
echo "It must start with either 'v0.' or 'v1.'."
|
||||
echo "Please, try again."
|
||||
exit 1
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: |
|
||||
release
|
||||
.github
|
||||
- name: Prepare release scripts
|
||||
uses: ./.github/actions/prepare-release-scripts
|
||||
- name: Get Release Version
|
||||
uses: actions/github-script@v9
|
||||
id: canonical_version
|
||||
with:
|
||||
script: | # js
|
||||
const { isValidVersionString, getCanonicalVersion, hasBeenReleased } = require('${{ github.workspace }}/release/dist/index.cjs');
|
||||
|
||||
const version = '${{ inputs.version }}';
|
||||
|
||||
if (!isValidVersionString(version)) {
|
||||
throw new Error("The version format is invalid! It must start with either 'v0.' or 'v1.'.");
|
||||
}
|
||||
|
||||
const versions = {
|
||||
ee: getCanonicalVersion(version, 'ee'),
|
||||
oss: getCanonicalVersion(version, 'oss'),
|
||||
};
|
||||
|
||||
const released = await hasBeenReleased({
|
||||
github,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
version: versions.oss,
|
||||
});
|
||||
|
||||
if (released) {
|
||||
throw new Error("This version has already been released!", version);
|
||||
}
|
||||
|
||||
return versions;
|
||||
|
||||
check-commit:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out the code to verify the release branch
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # IMPORTANT! to get all the branches
|
||||
sparse-checkout: |
|
||||
release
|
||||
.github
|
||||
- name: Prepare release scripts
|
||||
uses: ./.github/actions/prepare-release-scripts
|
||||
- name: Get Release Branch
|
||||
uses: actions/github-script@v9
|
||||
id: release_branch
|
||||
with:
|
||||
result-encoding: string
|
||||
script: | # js
|
||||
const { getReleaseBranch } = require('${{ github.workspace }}/release/dist/index.cjs');
|
||||
const version = '${{ inputs.version }}';
|
||||
const releaseBranch = getReleaseBranch(version);
|
||||
return releaseBranch;
|
||||
- name: Ensure that the specified commit exists in the ${{ steps.release_branch.outputs.result }} release branch
|
||||
run: | # bash
|
||||
RELEASE_BRANCH=${{ steps.release_branch.outputs.result }}
|
||||
git checkout "$RELEASE_BRANCH"
|
||||
if git branch --contains ${{ inputs.commit }} | grep -q "$RELEASE_BRANCH"; then
|
||||
echo "Commit found in correct release branch"
|
||||
else
|
||||
echo "Commit not found in correct release branch"
|
||||
exit 1
|
||||
fi
|
||||
git checkout -
|
||||
|
||||
get-build-requirements:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
java_version: ${{ fromJson(steps.dependencies.outputs.result).java_version }}
|
||||
node_version: ${{ fromJson(steps.dependencies.outputs.result).node_version }}
|
||||
build_process: ${{ fromJson(steps.dependencies.outputs.result).build_process }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: |
|
||||
release
|
||||
.github
|
||||
- name: Prepare release scripts
|
||||
uses: ./.github/actions/prepare-release-scripts
|
||||
- name: Get build dependencies
|
||||
uses: actions/github-script@v9
|
||||
id: dependencies
|
||||
with:
|
||||
script: | # js
|
||||
const { getBuildRequirements, getMajorVersion } = require('${{ github.workspace }}/release/dist/index.cjs');
|
||||
|
||||
const version = '${{ inputs.version }}';
|
||||
|
||||
const requirements = getBuildRequirements(version);
|
||||
const build_process = getMajorVersion(version) < 46 ? 'legacy' : 'new';
|
||||
console.log('Build process:', build_process);
|
||||
|
||||
return {
|
||||
java_version: requirements.java,
|
||||
node_version: requirements.node,
|
||||
build_process,
|
||||
};
|
||||
|
||||
manage-milestones:
|
||||
# Close the milestone we're about to ship and open the next one BEFORE building,
|
||||
# so any PRs that merge while the build is in flight are tagged with the next
|
||||
# milestone (not the one about to be released).
|
||||
#
|
||||
# Sequenced after trigger-milestone-check so that the check sees the milestone
|
||||
# in its open state. milestone-check is informational and must never block, so
|
||||
# we run regardless of its outcome; only preflight failures suppress this.
|
||||
needs: [check-version, check-commit, trigger-milestone-check]
|
||||
if: ${{ !cancelled() && needs.check-version.result == 'success' && needs.check-commit.result == 'success' }}
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
uses: ./.github/workflows/release-milestone-manage.yml
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
|
||||
build-uberjar-for-release:
|
||||
needs: [check-version, check-commit, get-build-requirements]
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 50
|
||||
strategy:
|
||||
matrix:
|
||||
edition: [oss, ee]
|
||||
env:
|
||||
INTERACTIVE: false
|
||||
steps:
|
||||
- name: Get correct version number
|
||||
run: | # bash
|
||||
if [ '${{matrix.edition}}' == 'ee' ]; then
|
||||
version='${{ needs.check-version.outputs.ee }}'
|
||||
else
|
||||
version='${{ needs.check-version.outputs.oss }}'
|
||||
fi
|
||||
|
||||
echo "VERSION=$version" >> "$GITHUB_ENV"
|
||||
- name: Setup Java ${{ needs.get-build-requirements.outputs.java_version }}
|
||||
uses: actions/setup-java@v5.2.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: ${{ needs.get-build-requirements.outputs.java_version }}
|
||||
- name: Setup Node.js ${{ needs.get-build-requirements.outputs.node_version }}
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: ${{ needs.get-build-requirements.outputs.node_version }}.x
|
||||
- name: Check out the code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.inputs.commit }}
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
|
||||
- name: Build Metabase
|
||||
if: ${{ needs.get-build-requirements.outputs.build_process != 'legacy' }}
|
||||
run: ./bin/build.sh :edition :${{ matrix.edition }} :version "$VERSION"
|
||||
|
||||
- name: Build Metabase (legacy)
|
||||
if: ${{ needs.get-build-requirements.outputs.build_process == 'legacy' }}
|
||||
run: cd ./bin/build-mb && clojure -X build/build! :edition :${{ matrix.edition }} :version "$VERSION"
|
||||
|
||||
- name: Store commit's SHA-1 hash
|
||||
run: echo ${{ inputs.commit }} > COMMIT-ID
|
||||
shell: bash
|
||||
- run: mv ./target/uberjar/metabase.jar ./metabase.jar
|
||||
- name: Calculate SHA256 checksum
|
||||
run: sha256sum ./metabase.jar > SHA256.sum
|
||||
shell: bash
|
||||
- name: Upload JARs as artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: metabase-release-${{ matrix.edition }}-${{ inputs.commit }}-uberjar
|
||||
path: |
|
||||
./metabase.jar
|
||||
./COMMIT-ID
|
||||
./SHA256.sum
|
||||
|
||||
run-pre-release-tests:
|
||||
name: Run pre-release tests for ${{ inputs.version }}
|
||||
needs: build-uberjar-for-release
|
||||
uses: ./.github/workflows/pre-release.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
commit: ${{ inputs.commit }}
|
||||
version: ${{ inputs.version }}
|
||||
4
.github/workflows/cache-generator.yml
vendored
4
.github/workflows/cache-generator.yml
vendored
@@ -93,10 +93,10 @@ jobs:
|
||||
":dev:ee:ee-dev:drivers:drivers-dev:test:eastwood" # backend.yml (eastwood + eastwood/test)
|
||||
":dev:ci:ee:ee-dev:drivers:drivers-dev:test" # test-driver.yml (ee) -> covers data.json:2.4.0
|
||||
":dev:ci:oss:oss-dev:drivers:drivers-dev:test" # test-driver.yml (oss)
|
||||
":dev:ci:test:ee:ee-dev" # backend.yml matrix (ee) + generative-query-test.yml
|
||||
":dev:ci:test:ee:ee-dev" # backend.yml matrix (ee)
|
||||
":dev:ci:test:oss:oss-dev" # backend.yml matrix (oss)
|
||||
":dev:ci:ee:ee-dev:test:cloverage" # backend-cloverage.yml
|
||||
":dev:drivers:drivers-dev:test" # reset-cloud-test-dataset.yml + presto script
|
||||
":dev:drivers:drivers-dev:test" # reset-cloud-test-dataset.yml
|
||||
":dev:drivers:build:build-dev:build-test:ci" # build-scripts.yml
|
||||
":ee:drivers:check" # backend.yml (check; git dep athos/clj-check)
|
||||
":ee:doc" # docs-generate-base.yml
|
||||
|
||||
39
.github/workflows/codeql.yml
vendored
39
.github/workflows/codeql.yml
vendored
@@ -1,39 +0,0 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'release-**'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
files-changed:
|
||||
name: Check which files changed
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
codeql: ${{ steps.changes.outputs.codeql }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Test which files changed
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
filters: .github/file-paths.yaml
|
||||
analyze:
|
||||
needs: files-changed
|
||||
if: needs.files-changed.outputs.codeql == 'true'
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: javascript
|
||||
config-file: ./.github/codeql/codeql-config.yml
|
||||
trap-caching: false
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
198
.github/workflows/drivers.yml
vendored
198
.github/workflows/drivers.yml
vendored
@@ -138,15 +138,6 @@ jobs:
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
be-tests-bigquery-cloud-sdk-ee:
|
||||
needs: determine-driver-skips
|
||||
@@ -211,15 +202,6 @@ jobs:
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
artifact-suffix: ${{ matrix.job.name }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -293,15 +275,6 @@ jobs:
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
artifact-suffix: ${{ matrix.job.name }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -346,15 +319,6 @@ jobs:
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
be-tests-databricks-ee:
|
||||
needs: determine-driver-skips
|
||||
@@ -417,15 +381,6 @@ jobs:
|
||||
artifact-suffix: ${{ matrix.job.name }}
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
be-tests-databricks-multi-catalog-ee:
|
||||
needs: determine-driver-skips
|
||||
@@ -489,15 +444,6 @@ jobs:
|
||||
artifact-suffix: ${{ matrix.job.name }}
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
be-tests-h2:
|
||||
needs: determine-driver-skips
|
||||
@@ -518,15 +464,6 @@ jobs:
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
be-tests-h2-oss:
|
||||
needs: determine-driver-skips
|
||||
@@ -548,15 +485,6 @@ jobs:
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
be-tests-mysql-mariadb:
|
||||
needs: determine-driver-skips
|
||||
@@ -688,15 +616,6 @@ jobs:
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Cleanup python-runner
|
||||
if: always()
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -767,15 +686,6 @@ jobs:
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
artifact-suffix: ${{ matrix.version.name }}-${{ matrix.job.name }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -848,15 +758,6 @@ jobs:
|
||||
:exclude-tags '[:mongo-sharded-cluster-tests :mb/transforms-python-test]'
|
||||
:only-tags [:mb/driver-tests]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
be-tests-mongo-sharded-cluster-ee:
|
||||
needs: determine-driver-skips
|
||||
@@ -892,15 +793,6 @@ jobs:
|
||||
test-args: >-
|
||||
:only metabase.driver.mongo.sharded-cluster-test
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
be-tests-oracle:
|
||||
needs: determine-driver-skips
|
||||
@@ -976,15 +868,6 @@ jobs:
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
be-tests-postgres:
|
||||
needs: determine-driver-skips
|
||||
@@ -1079,15 +962,6 @@ jobs:
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[ ${{ matrix.job.exclude-tag }} ${{ env.__ADDITIONAL_EXCLUDED_TAG__ }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Cleanup python-runner
|
||||
if: always()
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -1165,15 +1039,6 @@ jobs:
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
be-tests-redshift-ee:
|
||||
needs: determine-driver-skips
|
||||
@@ -1248,15 +1113,6 @@ jobs:
|
||||
${{ matrix.job.test-args }}
|
||||
artifact-suffix: ${{ matrix.job.name }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: ${{ !matrix.job.skip && !cancelled() }}
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -1330,15 +1186,6 @@ jobs:
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
artifact-suffix: ${{ matrix.job.name }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -1381,15 +1228,6 @@ jobs:
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
be-tests-sqlite-ee:
|
||||
needs: determine-driver-skips
|
||||
@@ -1421,15 +1259,6 @@ jobs:
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
be-tests-sqlserver:
|
||||
needs: determine-driver-skips
|
||||
@@ -1496,15 +1325,6 @@ jobs:
|
||||
test-args: ${{ matrix.job.test-args }}
|
||||
artifact-suffix: ${{ matrix.version.name }}-${{ matrix.job.name }}
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Cleanup python-runner
|
||||
if: ${{ always() && matrix.job.needs-python-runner == true }}
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -1539,15 +1359,6 @@ jobs:
|
||||
# junit-name: 'be-tests-starburst-ee'
|
||||
# test-args: >-
|
||||
# :only-tags [:mb/driver-tests]
|
||||
# - name: Upload Test Results
|
||||
# uses: ./.github/actions/upload-test-results
|
||||
# if: always()
|
||||
# with:
|
||||
# input-path: ./target/junit/
|
||||
# output-name: ${{ github.job }}
|
||||
# bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
# role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
# aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
login-to-ecr:
|
||||
needs: determine-driver-skips
|
||||
@@ -1621,15 +1432,6 @@ jobs:
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
drivers-tests-result:
|
||||
needs:
|
||||
|
||||
11
.github/workflows/e2e-test.yml
vendored
11
.github/workflows/e2e-test.yml
vendored
@@ -216,17 +216,6 @@ jobs:
|
||||
echo "No ./target/junit directory; nothing to process."
|
||||
fi
|
||||
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit
|
||||
# Related to our custom upload to S3
|
||||
output-name: e2e-${{ inputs.name }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
- name: Quarantine gate
|
||||
# Only one of the two run steps executes; the other reports `skipped`. Only gate on actual failure.
|
||||
if: ${{ !cancelled() && (steps.split-e2e.outcome == 'failure' || steps.tagged-e2e.outcome == 'failure') }}
|
||||
|
||||
@@ -111,16 +111,6 @@ jobs:
|
||||
echo "No ./target/junit directory; nothing to process."
|
||||
fi
|
||||
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit
|
||||
output-name: e2e-embedding-sdk-react-${{ matrix.react-version }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
- name: Quarantine gate
|
||||
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
|
||||
continue-on-error: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
@@ -290,16 +280,6 @@ jobs:
|
||||
echo "No ./target/junit directory; nothing to process."
|
||||
fi
|
||||
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit
|
||||
output-name: e2e-embedding-sdk
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
- name: Quarantine gate
|
||||
if: ${{ !cancelled() && steps.run-tests.outcome == 'failure' }}
|
||||
continue-on-error: ${{ vars.QUARANTINE_ENGINE != 'ci-conductor' }}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
name: Escalation Slack Notification
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- labeled
|
||||
jobs:
|
||||
on-escalation-label:
|
||||
if: github.event.label.name == '.Escalation'
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Format issue title (escape quotes)
|
||||
uses: actions/github-script@v9
|
||||
id: vars
|
||||
env:
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
with:
|
||||
script: |
|
||||
core.setOutput('issue_title', process.env.ISSUE_TITLE.replaceAll(/"/g, '\\"'));
|
||||
- name: Send escalated issue ${{ github.event.issue.number }} to Slack
|
||||
id: slack
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_ESCALATIONS_WEBHOOK_URL }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "🔥🔥🔥 ${{ steps.vars.outputs.issue_title }}\n${{ github.event.issue.html_url }}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
9
.github/workflows/frontend.yml
vendored
9
.github/workflows/frontend.yml
vendored
@@ -129,15 +129,6 @@ jobs:
|
||||
adapter: frontend
|
||||
test-suite: fe-tests-unit
|
||||
enforce-quarantine: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always() && github.event_name != 'workflow_dispatch'
|
||||
with:
|
||||
input-path: ./target/junit
|
||||
output-name: frontend-unit
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
fe-tests-timezones:
|
||||
if: ${{ !inputs.skip }}
|
||||
|
||||
58
.github/workflows/generative-query-test.yml
vendored
58
.github/workflows/generative-query-test.yml
vendored
@@ -1,58 +0,0 @@
|
||||
name: Generative Query Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
context-seed:
|
||||
description: 'Context seed (empty = random)'
|
||||
type: number
|
||||
iteration-seed:
|
||||
description: 'Iteration seed (for iteration replay, empty = dependent on Context seed)'
|
||||
type: number
|
||||
iterations:
|
||||
description: 'Iterations (number of test iterations, overrides seconds)'
|
||||
type: number
|
||||
seconds:
|
||||
description: 'Seconds (Number of seconds after which next iteration will not start)'
|
||||
test:
|
||||
description: 'Test or namespace to run'
|
||||
default: metabase.query-processor.generative-test/execution-with-cards-test
|
||||
ref:
|
||||
description: 'Ref to run generative tests on'
|
||||
default: qgen-ms2
|
||||
|
||||
# TODO: Consider renaming the job in next milestones
|
||||
jobs:
|
||||
generative-query-test:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 90
|
||||
name: Generative Query Test
|
||||
env:
|
||||
CI: 'true'
|
||||
MB_GENTEST_CONTEXT_SEED: ${{ inputs.context-seed }}
|
||||
MB_GENTEST_ITERATION_SEED: ${{ inputs.iteration-seed }}
|
||||
MB_GENTEST_LIMIT_ITERATIONS: ${{ inputs.iterations }}
|
||||
MB_GENTEST_LIMIT_SECONDS: ${{ inputs.seconds }}
|
||||
MB_GENTEST_RUN: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
|
||||
- name: Run test
|
||||
run: clojure -X:dev:ci:test:ee:ee-dev :only '${{ inputs.test }}'
|
||||
|
||||
- name: Publish Test Report (JUnit)
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
if: failure()
|
||||
with:
|
||||
path: "target/junit/**/*_test.xml"
|
||||
name: JUnit Test Report (Generative Query test)
|
||||
reporter: java-junit
|
||||
list-suites: failed
|
||||
list-tests: failed
|
||||
fail-on-error: false
|
||||
51
.github/workflows/i18n.yml
vendored
51
.github/workflows/i18n.yml
vendored
@@ -1,51 +0,0 @@
|
||||
name: i18n
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'release-**'
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
files-changed:
|
||||
name: Check which files changed
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
i18n: ${{ steps.changes.outputs.i18n }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Test which files changed
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
filters: .github/file-paths.yaml
|
||||
|
||||
verify-i18n-files:
|
||||
needs: files-changed
|
||||
if: needs.files-changed.outputs.i18n == 'true'
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Install xgettext if necessary
|
||||
run: |
|
||||
if ! command -v xgettext > /dev/null; then
|
||||
sudo apt install gettext
|
||||
fi
|
||||
- name: Check xgettext version
|
||||
run: xgettext --version
|
||||
- name: Check i18n tags/make sure template can be built
|
||||
run: ./bin/i18n/update-translation-template
|
||||
- name: Verify i18n translations (.po files)
|
||||
run: ./bin/i18n/build-translation-resources
|
||||
184
.github/workflows/lint.yml
vendored
Normal file
184
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,184 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'release-**'
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
files-changed:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
actionlint: ${{ steps.changes.outputs.actionlint }}
|
||||
actionlint_files: ${{ steps.changes.outputs.actionlint_files }}
|
||||
codeql: ${{ steps.changes.outputs.codeql }}
|
||||
i18n: ${{ steps.changes.outputs.i18n }}
|
||||
openapi: ${{ steps.changes.outputs.openapi }}
|
||||
snowplow: ${{ steps.changes.outputs.snowplow }}
|
||||
yaml: ${{ steps.changes.outputs.yaml }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Test which files changed
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
filters: .github/file-paths.yaml
|
||||
list-files: shell
|
||||
|
||||
lint:
|
||||
needs: files-changed
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend env
|
||||
if: |
|
||||
needs.files-changed.outputs.yaml == 'true' ||
|
||||
needs.files-changed.outputs.openapi == 'true'
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
|
||||
# Always required by whitespace linter, also used by OpenAPI spec validation
|
||||
- name: Setup backend env
|
||||
uses: ./.github/actions/prepare-backend
|
||||
|
||||
- name: Setup actionlint
|
||||
if: needs.files-changed.outputs.actionlint == 'true'
|
||||
id: download
|
||||
shell: bash
|
||||
run: |
|
||||
if command -v actionlint; then
|
||||
echo "executable=actionlint" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# script sets executable output, for details see: https://github.com/rhysd/actionlint/blob/main/docs/usage.md#use-actionlint-on-github-actions
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.12/scripts/download-actionlint.bash)
|
||||
fi
|
||||
|
||||
- parallel:
|
||||
# Lint whitespace, always runs
|
||||
- name: Run Whitespace Linter
|
||||
run: clojure -T:whitespace-linter lint :whitespace-linter/progress-bar false
|
||||
|
||||
# Lint YAML
|
||||
- name: Lint YAML files
|
||||
if: needs.files-changed.outputs.yaml == 'true'
|
||||
run: bun run lint-yaml
|
||||
|
||||
# Lint Github Actions
|
||||
- name: Run actionlint
|
||||
if: needs.files-changed.outputs.actionlint == 'true'
|
||||
run: ${{ steps.download.outputs.executable }} --color --verbose ${{ needs.files-changed.outputs.actionlint_files }}
|
||||
|
||||
# Generate and Lint OpenAPI spec
|
||||
- name: Generate OpenAPI spec
|
||||
if: needs.files-changed.outputs.openapi == 'true'
|
||||
run: bun run generate-openapi
|
||||
|
||||
# Parallel steps run asynchronously, and explicitly waiting for a parallel step means "if" statements are ignored.
|
||||
# Move OpenAPI validation outside the parallel block so that spec generation happens asynchronously without requiring
|
||||
# an explicit "wait".
|
||||
- name: Validate OpenAPI spec
|
||||
if: needs.files-changed.outputs.openapi == 'true'
|
||||
run: bun run lint-openapi
|
||||
|
||||
codeql:
|
||||
if: needs.files-changed.outputs.codeql == 'true'
|
||||
needs: files-changed
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: javascript
|
||||
config-file: ./.github/codeql/codeql-config.yml
|
||||
trap-caching: false
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
|
||||
i18n:
|
||||
needs: files-changed
|
||||
if: needs.files-changed.outputs.i18n == 'true'
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Install xgettext if necessary
|
||||
run: |
|
||||
if ! command -v xgettext > /dev/null; then
|
||||
sudo apt install gettext
|
||||
fi
|
||||
- name: Check xgettext version
|
||||
run: xgettext --version
|
||||
- name: Check i18n tags/make sure template can be built
|
||||
run: ./bin/i18n/update-translation-template
|
||||
- name: Verify i18n translations (.po files)
|
||||
run: ./bin/i18n/build-translation-resources
|
||||
|
||||
snowplow:
|
||||
needs: files-changed
|
||||
if: needs.files-changed.outputs.snowplow == 'true'
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v5.2.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 25
|
||||
- name: Set up Babashka
|
||||
uses: turtlequeue/setup-babashka@d78ec6570aea3b614bc695cafcceb82eb45c2af9 # v1.8.0
|
||||
with:
|
||||
babashka-version: 1.12.197
|
||||
- name: Install igluctl
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/igluctl.zip" https://github.com/snowplow/igluctl/releases/download/0.13.0/igluctl_0.13.0.zip
|
||||
echo "0eac19121fca1431dec44738ada8eacc4242811a95d015ee71e00deec2b4d748 $RUNNER_TEMP/igluctl.zip" | sha256sum -c -
|
||||
unzip -q "$RUNNER_TEMP/igluctl.zip" -d "$RUNNER_TEMP/igluctl"
|
||||
chmod +x "$RUNNER_TEMP/igluctl/igluctl"
|
||||
echo "$RUNNER_TEMP/igluctl" >> "$GITHUB_PATH"
|
||||
- name: Lint (igluctl)
|
||||
# igluctl recursively parses every file as JSON, so it chokes on colocated
|
||||
# *.md docs and on two schemas whose internal `self.version` doesn't match
|
||||
# the filename version (pending Snowcat audit). Stage a filtered copy and
|
||||
# patch only the `self.version` mismatch so igluctl still lints the rest
|
||||
# of those schemas.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LINT_ROOT="$RUNNER_TEMP/iglu-lint"
|
||||
rm -rf "$LINT_ROOT"
|
||||
mkdir -p "$LINT_ROOT"
|
||||
cp -r snowplow/iglu-client-embedded/schemas "$LINT_ROOT/"
|
||||
find "$LINT_ROOT" -name '*.md' -delete
|
||||
# TODO(BOT-1403): drop these patches once the Snowcat audit fixes the
|
||||
# internal `self.version` mismatch on these two schemas. We rewrite
|
||||
# only `self.version` in the staged copy so the file's path version
|
||||
# matches what igluctl expects; every other lint rule still runs.
|
||||
# Each `sed` invocation uses the schema's wrong/right pair, so it
|
||||
# silently no-ops once the underlying schema is fixed.
|
||||
sed -i 's/"version": "1-0-1"/"version": "1-0-2"/' \
|
||||
"$LINT_ROOT/schemas/com.metabase/embed_share/jsonschema/1-0-2"
|
||||
sed -i 's/"version": "1-0-0"/"version": "1-0-1"/' \
|
||||
"$LINT_ROOT/schemas/com.metabase/serialization/jsonschema/1-0-1"
|
||||
igluctl lint "$LINT_ROOT/schemas"
|
||||
- name: Lint (Metabase schema rules)
|
||||
run: ./bin/lint-snowplow-schemas.bb
|
||||
- name: Test linter rules
|
||||
run: ./bin/lint-snowplow-schemas-test.bb
|
||||
39
.github/workflows/main.yml
vendored
39
.github/workflows/main.yml
vendored
@@ -1,39 +0,0 @@
|
||||
name: Slack Notification for New Feature Requests
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- labeled
|
||||
jobs:
|
||||
on-issue-labeled:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
if: |
|
||||
contains(github.event.issue.labels.*.name, 'Type:New Feature')
|
||||
steps:
|
||||
- name: Setting title
|
||||
uses: actions/github-script@v9
|
||||
id: vars
|
||||
env:
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
with:
|
||||
script: |
|
||||
core.setOutput('issue_title', process.env.ISSUE_TITLE.replaceAll(/"/g, '\\"'));
|
||||
- name: Send the issue ${{ github.event.issue.number }} to Slack
|
||||
id: slack
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_FEATURE_REQUEST_TRIAGE_WEBHOOK_URL }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "New incoming feature request: ${{ steps.vars.outputs.issue_title }}\n${{ github.event.issue.html_url }}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
28
.github/workflows/minimal-tests.yml
vendored
28
.github/workflows/minimal-tests.yml
vendored
@@ -9,7 +9,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
checks: write
|
||||
actions: read
|
||||
@@ -70,15 +69,6 @@ jobs:
|
||||
:exclude-tags '[:mb/driver-tests :mb/transforms-python-test]'
|
||||
:partition/total 2
|
||||
:partition/index 0
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
# Postgres driver tests
|
||||
postgres-driver-test:
|
||||
@@ -116,15 +106,6 @@ jobs:
|
||||
test-args: >-
|
||||
:only-tags [:mb/driver-tests]
|
||||
:exclude-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
# Frontend unit tests
|
||||
frontend-unit-tests:
|
||||
@@ -168,15 +149,6 @@ jobs:
|
||||
adapter: frontend
|
||||
test-suite: fe-tests-unit
|
||||
enforce-quarantine: ${{ vars.QUARANTINE_ENGINE == 'ci-conductor' }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit
|
||||
output-name: frontend-unit
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
# Summary job
|
||||
minimal-tests-result:
|
||||
|
||||
208
.github/workflows/notify.issue-labeled.yml
vendored
Normal file
208
.github/workflows/notify.issue-labeled.yml
vendored
Normal file
@@ -0,0 +1,208 @@
|
||||
# Send Slack and/or PagerDuty notifications when labels are added to a Github issue.
|
||||
name: Notify on Issue Labeled
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- labeled
|
||||
|
||||
jobs:
|
||||
on-escalation-label:
|
||||
if: github.event.label.name == '.Escalation'
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Format issue title (escape quotes)
|
||||
uses: actions/github-script@v9
|
||||
id: vars
|
||||
env:
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
with:
|
||||
script: |
|
||||
core.setOutput('issue_title', process.env.ISSUE_TITLE.replaceAll(/"/g, '\\"'));
|
||||
- name: Send escalated issue ${{ github.event.issue.number }} to Slack
|
||||
id: slack
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_ESCALATIONS_WEBHOOK_URL }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "🔥🔥🔥 ${{ steps.vars.outputs.issue_title }}\n${{ github.event.issue.html_url }}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
on-security-label:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
if: github.event.label.name == 'SECURITY'
|
||||
steps:
|
||||
- name: Setting title
|
||||
uses: actions/github-script@v9
|
||||
id: vars
|
||||
env:
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
with:
|
||||
script: |
|
||||
core.setOutput('issue_title', process.env.ISSUE_TITLE.replaceAll(/"/g, '\\"'));
|
||||
- name: Send the issue ${{ github.event.issue.number }} to Slack
|
||||
id: slack
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_SECURITY_WEBHOOK_URL }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "${{ steps.vars.outputs.issue_title }}\n${{ github.event.issue.html_url }}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Note: this only triggers if a issue is already labeled with `Type:Bug` and `flaky-test-fix` and then a team label is added.
|
||||
on-team-label:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
if: |
|
||||
contains(github.event.label.name, '.Team/') &&
|
||||
( contains(github.event.issue.labels.*.name, 'Type:Bug') ||
|
||||
contains(github.event.issue.labels.*.name, 'flaky-test-fix') )
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
- name: Build Slack message (with Linear link if available)
|
||||
uses: actions/github-script@v9
|
||||
id: vars
|
||||
env:
|
||||
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
|
||||
with:
|
||||
script: |
|
||||
const { findLinearIssue } = require('./.github/scripts/find-linear-issue.js');
|
||||
|
||||
const title = context.payload.issue.title;
|
||||
const issueUrl = context.payload.issue.html_url;
|
||||
|
||||
let text = title + '\n' + issueUrl;
|
||||
|
||||
// The Linear issue is usually already synced by the time the
|
||||
// team label lands at triage, but retry briefly just in case.
|
||||
try {
|
||||
const delaysSeconds = [0, 15, 30];
|
||||
let result = null;
|
||||
|
||||
for (const delay of delaysSeconds) {
|
||||
if (delay) await new Promise((r) => setTimeout(r, delay * 1000));
|
||||
result = await findLinearIssue({
|
||||
githubIssueUrl: issueUrl,
|
||||
linearApiKey: process.env.LINEAR_API_KEY,
|
||||
});
|
||||
if (result.success) break;
|
||||
}
|
||||
|
||||
if (result && result.success) {
|
||||
const id = result.issue; // e.g. "GIT-1234"
|
||||
text += `\nLinear: <https://linear.app/metabase/issue/${id}|${id}>`;
|
||||
} else {
|
||||
core.info(`No Linear issue found for ${issueUrl}; posting without link.`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Never block the notification on a Linear lookup failure
|
||||
core.warning(`Linear lookup failed: ${e.message}`);
|
||||
}
|
||||
|
||||
core.setOutput('message', JSON.stringify(text));
|
||||
|
||||
- name: Extracting team name
|
||||
id: team_name
|
||||
env:
|
||||
LABEL_NAME: ${{ github.event.label.name }}
|
||||
run: |
|
||||
team_label="${LABEL_NAME//&/$}"
|
||||
echo "$team_label"
|
||||
team_name=${team_label#*.Team/}
|
||||
echo "$team_name"
|
||||
echo "team_name=${team_name^^}_SLACK_ISSUES_WEBHOOK_URL" >> "$GITHUB_OUTPUT"
|
||||
- name: Check webhook secret exists
|
||||
id: check_secret
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets[steps.team_name.outputs.team_name] }}
|
||||
run: |
|
||||
if [ -z "$WEBHOOK_URL" ]; then
|
||||
echo "Secret ${{ steps.team_name.outputs.team_name }} is not set, skipping Slack notification"
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Send the issue ${{ github.event.issue.number }} to Slack
|
||||
if: steps.check_secret.outputs.exists == 'true'
|
||||
id: slack
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets[steps.team_name.outputs.team_name] }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": ${{ steps.vars.outputs.message }}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
on-escalation-and-team-querying-label:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
if: |
|
||||
(github.event.label.name == 'Priority:P0' || github.event.label.name == 'Priority:P1' || github.event.label.name == '.Escalation') &&
|
||||
contains(github.event.issue.labels.*.name, '.Team/Querying')
|
||||
steps:
|
||||
- name: Trigger PagerDuty Incident
|
||||
env:
|
||||
ROUTING_KEY: ${{ secrets.QUERYING_PAGERDUTY_INTEGRATION_KEY }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
LABEL_NAME: ${{ github.event.label.name }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
ISSUE_URL: ${{ github.event.issue.html_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
ALL_LABELS: ${{ join(github.event.issue.labels.*.name, ', ') }}
|
||||
run: |
|
||||
payload=$(jq -n \
|
||||
--arg rk "$ROUTING_KEY" \
|
||||
--arg dk "github-issue-$ISSUE_NUMBER" \
|
||||
--arg sum "[$LABEL_NAME] $ISSUE_TITLE" \
|
||||
--arg src "GitHub Issues - $REPO" \
|
||||
--arg lbl "$ALL_LABELS" \
|
||||
--arg url "$ISSUE_URL" \
|
||||
'{
|
||||
routing_key: $rk,
|
||||
event_action: "trigger",
|
||||
dedup_key: $dk,
|
||||
payload: {
|
||||
summary: $sum,
|
||||
source: $src,
|
||||
severity: "critical",
|
||||
custom_details: { Labels: $lbl }
|
||||
},
|
||||
links: [{ href: $url, text: "View GitHub Issue" }]
|
||||
}')
|
||||
curl -X POST https://events.pagerduty.com/v2/enqueue \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload"
|
||||
44
.github/workflows/openapi.yml
vendored
44
.github/workflows/openapi.yml
vendored
@@ -1,44 +0,0 @@
|
||||
name: Validate OpenAPI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'release-**'
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref && github.ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
files-changed:
|
||||
name: Check which files changed
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
openapi: ${{ steps.changes.outputs.openapi }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Test which files changed
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
filters: .github/file-paths.yaml
|
||||
|
||||
validate-openapi:
|
||||
needs: files-changed
|
||||
if: needs.files-changed.outputs.openapi == 'true'
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- name: Generate OpenAPI spec
|
||||
run: bun run generate-openapi
|
||||
- name: Validate OpenAPI spec
|
||||
run: bun run lint-openapi
|
||||
@@ -1,109 +0,0 @@
|
||||
# this file uses MBA https://github.com/metabase/mba to spin up a Metabase
|
||||
# instance then runs the test script in the MBA instance sharing the same
|
||||
# network to interact with presto container
|
||||
# TODO: get rid of MBA and use actions/prepare-backend instead
|
||||
|
||||
name: Kerberized Presto Integration Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- "release**"
|
||||
- "feature**"
|
||||
tags:
|
||||
- "**"
|
||||
|
||||
jobs:
|
||||
files-changed:
|
||||
name: Check which files changed
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
backend_presto_kerberos: ${{ steps.changes.outputs.backend_presto_kerberos }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Test which files changed
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
filters: .github/file-paths.yaml
|
||||
|
||||
run-presto-kerberos-test:
|
||||
needs: files-changed
|
||||
if: needs.files-changed.outputs.backend_presto_kerberos == 'true'
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Install babashka
|
||||
run: >
|
||||
mkdir -p /tmp/babashka-install \
|
||||
&& cd /tmp/babashka-install \
|
||||
&& curl -sLO https://raw.githubusercontent.com/babashka/babashka/master/install \
|
||||
&& chmod +x install \
|
||||
&& sudo ./install \
|
||||
&& cd -
|
||||
- name: Checkout Metabase repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Check out Presto Kerberos Docker Compose
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: metabase/presto-kerberos-docker
|
||||
ref: master
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path: presto-kerberos-docker
|
||||
- name: Bring up Presto+Kerberos cluster
|
||||
run: cd presto-kerberos-docker && docker compose up -d && cd ..
|
||||
- name: Run Presto test query from command line (sanity check)
|
||||
run: cd presto-kerberos-docker && ./test.sh && cd ..
|
||||
# Since we are managing the Docker containers from the GitHub action container, we need to copy all the
|
||||
# relevant resources now, into the resources dir for later consumption by the app
|
||||
- name: Copy Presto SSL keystore to resources
|
||||
run: docker cp presto-kerberos:/tmp/ssl_keystore.jks resources
|
||||
- name: Copy krb5.conf file to resources
|
||||
run: docker cp presto-kerberos:/etc/krb5.conf resources
|
||||
- name: Copy client.keytab file to resources
|
||||
run: docker cp presto-kerberos:/home/presto/client.keytab resources
|
||||
|
||||
- name: Checkout mba
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: metabase/mba
|
||||
ref: master
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path: mba-src
|
||||
- name: ls mba
|
||||
run: ls -latr mba-src
|
||||
- name: Symlink mba
|
||||
run: cd mba-src && sudo ln -s "$(pwd)/src/main.clj" /usr/local/bin/mba && chmod +x /usr/local/bin/mba && cd ..
|
||||
|
||||
- name: Ensure mba
|
||||
run: which mba
|
||||
|
||||
- name: Run Metabase via MBA
|
||||
run: mba --mb . --data-db postgres-data -n example.com compose config > mba-compose.yml
|
||||
|
||||
- name: Adjust generated docker-compose config
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: | #js
|
||||
const fs = require("fs");
|
||||
const config = fs.readFileSync("mba-compose.yml", "utf8");
|
||||
const newConfig = config.replace(
|
||||
/name:\s+example.com/,
|
||||
"name: example.com\n external: true",
|
||||
);
|
||||
fs.writeFileSync("mba-compose.yml", newConfig);
|
||||
|
||||
- name: Print compose config
|
||||
run: cat mba-compose.yml
|
||||
|
||||
- name: Run mba
|
||||
run: docker compose -f mba-compose.yml up -d
|
||||
|
||||
- name: Run test script in MBA instance
|
||||
run: docker compose -f mba-compose.yml exec metabase .github/scripts/run-presto-kerberos-integration-test.sh
|
||||
47
.github/workflows/querying-team-pagerduty.yml
vendored
47
.github/workflows/querying-team-pagerduty.yml
vendored
@@ -1,47 +0,0 @@
|
||||
name: Querying On-call PagerDuty Alert
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- labeled
|
||||
|
||||
jobs:
|
||||
notify-querying-on-call:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
if: |
|
||||
(github.event.label.name == 'Priority:P0' || github.event.label.name == 'Priority:P1' || github.event.label.name == '.Escalation') &&
|
||||
contains(github.event.issue.labels.*.name, '.Team/Querying')
|
||||
steps:
|
||||
- name: Trigger PagerDuty Incident
|
||||
env:
|
||||
ROUTING_KEY: ${{ secrets.QUERYING_PAGERDUTY_INTEGRATION_KEY }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
LABEL_NAME: ${{ github.event.label.name }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
ISSUE_URL: ${{ github.event.issue.html_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
ALL_LABELS: ${{ join(github.event.issue.labels.*.name, ', ') }}
|
||||
run: |
|
||||
payload=$(jq -n \
|
||||
--arg rk "$ROUTING_KEY" \
|
||||
--arg dk "github-issue-$ISSUE_NUMBER" \
|
||||
--arg sum "[$LABEL_NAME] $ISSUE_TITLE" \
|
||||
--arg src "GitHub Issues - $REPO" \
|
||||
--arg lbl "$ALL_LABELS" \
|
||||
--arg url "$ISSUE_URL" \
|
||||
'{
|
||||
routing_key: $rk,
|
||||
event_action: "trigger",
|
||||
dedup_key: $dk,
|
||||
payload: {
|
||||
summary: $sum,
|
||||
source: $src,
|
||||
severity: "critical",
|
||||
custom_details: { Labels: $lbl }
|
||||
},
|
||||
links: [{ href: $url, text: "View GitHub Issue" }]
|
||||
}')
|
||||
curl -X POST https://events.pagerduty.com/v2/enqueue \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload"
|
||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -381,7 +381,8 @@ jobs:
|
||||
dot-x-tag: ${{ startsWith(matrix.tag, 'v') }}
|
||||
|
||||
trigger-cloud-issues:
|
||||
if: ${{ needs.check-version.outputs.kind == 'minor' || needs.check-version.outputs.kind == 'major' }}
|
||||
# Only create issues in the cloud ops repository if this release happens in https://github.com/metabase/metabase.
|
||||
if: ${{ github.repository == 'metabase/metabase' && (needs.check-version.outputs.kind == 'minor' || needs.check-version.outputs.kind == 'major') }}
|
||||
needs: [push-tags, check-version]
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
name: Security Issue Slack Notification
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- labeled
|
||||
|
||||
jobs:
|
||||
on-issue-opened:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
if: github.event.label.name == 'SECURITY'
|
||||
steps:
|
||||
- name: Setting title
|
||||
uses: actions/github-script@v9
|
||||
id: vars
|
||||
env:
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
with:
|
||||
script: |
|
||||
core.setOutput('issue_title', process.env.ISSUE_TITLE.replaceAll(/"/g, '\\"'));
|
||||
- name: Send the issue ${{ github.event.issue.number }} to Slack
|
||||
id: slack
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_SECURITY_WEBHOOK_URL }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "${{ steps.vars.outputs.issue_title }}\n${{ github.event.issue.html_url }}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
45
.github/workflows/semantic-search.yml
vendored
45
.github/workflows/semantic-search.yml
vendored
@@ -63,15 +63,6 @@ jobs:
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
semantic-search-tests-mariadb:
|
||||
if: ${{ !inputs.skip }}
|
||||
@@ -138,15 +129,6 @@ jobs:
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
semantic-search-tests-mysql:
|
||||
if: ${{ !inputs.skip }}
|
||||
@@ -220,15 +202,6 @@ jobs:
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
semantic-search-tests-postgres:
|
||||
if: ${{ !inputs.skip }}
|
||||
@@ -294,15 +267,6 @@ jobs:
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
# pgvector-on-the-app-db mode: the app db itself is a pgvector-capable Postgres and
|
||||
# MB_PGVECTOR_DB_URL is deliberately unset. The dedicated-harness tests gate themselves off; the
|
||||
@@ -358,15 +322,6 @@ jobs:
|
||||
${{ matrix.job.test-args }}
|
||||
:exclude-tags '[${{ matrix.job.exclude-tag }}]'
|
||||
quarantine-engine: ${{ vars.QUARANTINE_ENGINE }}
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
|
||||
semantic-search-tests-result:
|
||||
needs:
|
||||
|
||||
2
.github/workflows/set-milestone.yml
vendored
2
.github/workflows/set-milestone.yml
vendored
@@ -15,6 +15,8 @@ on:
|
||||
jobs:
|
||||
set-milestone:
|
||||
name: Automatically set milestone
|
||||
# Only run set-milestone in https://github.com/metabase/metabase since this is the only place milestones are used.
|
||||
if: github.repository == 'metabase/metabase'
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
|
||||
75
.github/workflows/snowplow.yml
vendored
75
.github/workflows/snowplow.yml
vendored
@@ -1,75 +0,0 @@
|
||||
name: Snowplow
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
|
||||
jobs:
|
||||
files-changed:
|
||||
name: Check which files changed
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
snowplow: ${{ steps.changes.outputs.snowplow }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Test which files changed
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
filters: .github/file-paths.yaml
|
||||
|
||||
lint:
|
||||
needs: files-changed
|
||||
if: needs.files-changed.outputs.snowplow == 'true'
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v5.2.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 25
|
||||
- name: Set up Babashka
|
||||
uses: turtlequeue/setup-babashka@d78ec6570aea3b614bc695cafcceb82eb45c2af9 # v1.8.0
|
||||
with:
|
||||
babashka-version: 1.12.197
|
||||
- name: Install igluctl
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/igluctl.zip" https://github.com/snowplow/igluctl/releases/download/0.13.0/igluctl_0.13.0.zip
|
||||
echo "0eac19121fca1431dec44738ada8eacc4242811a95d015ee71e00deec2b4d748 $RUNNER_TEMP/igluctl.zip" | sha256sum -c -
|
||||
unzip -q "$RUNNER_TEMP/igluctl.zip" -d "$RUNNER_TEMP/igluctl"
|
||||
chmod +x "$RUNNER_TEMP/igluctl/igluctl"
|
||||
echo "$RUNNER_TEMP/igluctl" >> "$GITHUB_PATH"
|
||||
- name: Lint (igluctl)
|
||||
# igluctl recursively parses every file as JSON, so it chokes on colocated
|
||||
# *.md docs and on two schemas whose internal `self.version` doesn't match
|
||||
# the filename version (pending Snowcat audit). Stage a filtered copy and
|
||||
# patch only the `self.version` mismatch so igluctl still lints the rest
|
||||
# of those schemas.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LINT_ROOT="$RUNNER_TEMP/iglu-lint"
|
||||
rm -rf "$LINT_ROOT"
|
||||
mkdir -p "$LINT_ROOT"
|
||||
cp -r snowplow/iglu-client-embedded/schemas "$LINT_ROOT/"
|
||||
find "$LINT_ROOT" -name '*.md' -delete
|
||||
# TODO(BOT-1403): drop these patches once the Snowcat audit fixes the
|
||||
# internal `self.version` mismatch on these two schemas. We rewrite
|
||||
# only `self.version` in the staged copy so the file's path version
|
||||
# matches what igluctl expects; every other lint rule still runs.
|
||||
# Each `sed` invocation uses the schema's wrong/right pair, so it
|
||||
# silently no-ops once the underlying schema is fixed.
|
||||
sed -i 's/"version": "1-0-1"/"version": "1-0-2"/' \
|
||||
"$LINT_ROOT/schemas/com.metabase/embed_share/jsonschema/1-0-2"
|
||||
sed -i 's/"version": "1-0-0"/"version": "1-0-1"/' \
|
||||
"$LINT_ROOT/schemas/com.metabase/serialization/jsonschema/1-0-1"
|
||||
igluctl lint "$LINT_ROOT/schemas"
|
||||
- name: Lint (Metabase schema rules)
|
||||
run: ./bin/lint-snowplow-schemas.bb
|
||||
- name: Test linter rules
|
||||
run: ./bin/lint-snowplow-schemas-test.bb
|
||||
9
.github/workflows/snyk.yml
vendored
9
.github/workflows/snyk.yml
vendored
@@ -10,6 +10,7 @@ on:
|
||||
- "**/package.json"
|
||||
- ".github/workflows/snyk.yml"
|
||||
- ".github/scripts/write-poms.sh"
|
||||
- ".github/scripts/fix-yarn-lock-aliases.ts"
|
||||
schedule:
|
||||
- cron: "0 5 * * *"
|
||||
|
||||
@@ -58,18 +59,22 @@ jobs:
|
||||
# Snyk doesn't support bun.lock yet, so we generate yarn.lock and scan that.
|
||||
# Bun has a bug where it normalizes "< 3.0.0" to "< 3" in alias entries
|
||||
# but not in dependency specs, causing Snyk to fail matching dependencies.
|
||||
# The sed commands fix this inconsistency. They continue on error to let
|
||||
# Snyk report parsing issues directly rather than failing on the workaround.
|
||||
# The sed commands fix this inconsistency. Bun also names aliased entries
|
||||
# after the aliased package instead of the alias, which the script fixes.
|
||||
# Both continue on error to let Snyk report parsing issues directly rather
|
||||
# than failing on the workaround.
|
||||
- name: Generate yarn.lock
|
||||
if: matrix.project.file == 'yarn.lock'
|
||||
run: |
|
||||
bun install --yarn
|
||||
sed -i -E 's/< ([0-9]+)\.0\.0"/< \1"/g; s/< ([0-9]+)\.0"/< \1"/g' yarn.lock || true
|
||||
bun ${{ github.workspace }}/.github/scripts/fix-yarn-lock-aliases.ts || true
|
||||
- name: Generate release/yarn.lock
|
||||
if: matrix.project.file == 'release/yarn.lock'
|
||||
run: |
|
||||
bun install --yarn
|
||||
sed -i -E 's/< ([0-9]+)\.0\.0"/< \1"/g; s/< ([0-9]+)\.0"/< \1"/g' yarn.lock || true
|
||||
bun ${{ github.workspace }}/.github/scripts/fix-yarn-lock-aliases.ts || true
|
||||
working-directory: release
|
||||
- uses: snyk/actions/setup@9adf32b1121593767fc3c057af55b55db032dc04 # v1.0.0
|
||||
- name: Run snyk test
|
||||
|
||||
114
.github/workflows/team-issues-slack-notification.yml
vendored
114
.github/workflows/team-issues-slack-notification.yml
vendored
@@ -1,114 +0,0 @@
|
||||
# .github/workflows/team-issues-slack-notification.yml
|
||||
#
|
||||
# Posts newly team-labeled bugs to the team's Slack channel (e.g.
|
||||
# #team-graphy-bugs), now including a link to the synced Linear issue.
|
||||
#
|
||||
# Changes from the current version:
|
||||
# - Checks out .github/scripts so we can reuse find-linear-issue.js
|
||||
# - The message-building step now queries Linear (via attachmentsForURL)
|
||||
# with a short retry, and appends "Linear: <link|ID>" when found
|
||||
# - Degrades gracefully: if no Linear issue is attached (or the API is
|
||||
# down), the message posts without the Linear issue link
|
||||
|
||||
name: Team Issues Slack Notification
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- labeled
|
||||
|
||||
jobs:
|
||||
on-team-label-added:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
if: |
|
||||
contains(github.event.label.name, '.Team/') &&
|
||||
( contains(github.event.issue.labels.*.name, 'Type:Bug') ||
|
||||
contains(github.event.issue.labels.*.name, 'flaky-test-fix') )
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
- name: Build Slack message (with Linear link if available)
|
||||
uses: actions/github-script@v9
|
||||
id: vars
|
||||
env:
|
||||
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
|
||||
with:
|
||||
script: |
|
||||
const { findLinearIssue } = require('./.github/scripts/find-linear-issue.js');
|
||||
|
||||
const title = context.payload.issue.title;
|
||||
const issueUrl = context.payload.issue.html_url;
|
||||
|
||||
let text = title + '\n' + issueUrl;
|
||||
|
||||
// The Linear issue is usually already synced by the time the
|
||||
// team label lands at triage, but retry briefly just in case.
|
||||
try {
|
||||
const delaysSeconds = [0, 15, 30];
|
||||
let result = null;
|
||||
|
||||
for (const delay of delaysSeconds) {
|
||||
if (delay) await new Promise((r) => setTimeout(r, delay * 1000));
|
||||
result = await findLinearIssue({
|
||||
githubIssueUrl: issueUrl,
|
||||
linearApiKey: process.env.LINEAR_API_KEY,
|
||||
});
|
||||
if (result.success) break;
|
||||
}
|
||||
|
||||
if (result && result.success) {
|
||||
const id = result.issue; // e.g. "GIT-1234"
|
||||
text += `\nLinear: <https://linear.app/metabase/issue/${id}|${id}>`;
|
||||
} else {
|
||||
core.info(`No Linear issue found for ${issueUrl}; posting without link.`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Never block the notification on a Linear lookup failure
|
||||
core.warning(`Linear lookup failed: ${e.message}`);
|
||||
}
|
||||
|
||||
core.setOutput('message', JSON.stringify(text));
|
||||
|
||||
- name: Extracting team name
|
||||
id: team_name
|
||||
env:
|
||||
LABEL_NAME: ${{ github.event.label.name }}
|
||||
run: |
|
||||
team_label="${LABEL_NAME//&/$}"
|
||||
echo "$team_label"
|
||||
team_name=${team_label#*.Team/}
|
||||
echo "$team_name"
|
||||
echo "team_name=${team_name^^}_SLACK_ISSUES_WEBHOOK_URL" >> "$GITHUB_OUTPUT"
|
||||
- name: Check webhook secret exists
|
||||
id: check_secret
|
||||
env:
|
||||
WEBHOOK_URL: ${{ secrets[steps.team_name.outputs.team_name] }}
|
||||
run: |
|
||||
if [ -z "$WEBHOOK_URL" ]; then
|
||||
echo "Secret ${{ steps.team_name.outputs.team_name }} is not set, skipping Slack notification"
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Send the issue ${{ github.event.issue.number }} to Slack
|
||||
if: steps.check_secret.outputs.exists == 'true'
|
||||
id: slack
|
||||
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
|
||||
with:
|
||||
webhook: ${{ secrets[steps.team_name.outputs.team_name] }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": ${{ steps.vars.outputs.message }}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -25,7 +25,6 @@ on:
|
||||
- sqlserver
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
checks: write
|
||||
actions: read
|
||||
@@ -64,15 +63,6 @@ jobs:
|
||||
granular-rerun: 'false'
|
||||
test-args: >-
|
||||
:only-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Cleanup python-runner
|
||||
if: always()
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -124,15 +114,6 @@ jobs:
|
||||
granular-rerun: 'false'
|
||||
test-args: >-
|
||||
:only-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Cleanup python-runner
|
||||
if: always()
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -174,15 +155,6 @@ jobs:
|
||||
granular-rerun: 'false'
|
||||
test-args: >-
|
||||
:only-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Cleanup python-runner
|
||||
if: always()
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -228,15 +200,6 @@ jobs:
|
||||
granular-rerun: 'false'
|
||||
test-args: >-
|
||||
:only-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Cleanup python-runner
|
||||
if: always()
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -283,15 +246,6 @@ jobs:
|
||||
granular-rerun: 'false'
|
||||
test-args: >-
|
||||
:only-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Cleanup python-runner
|
||||
if: always()
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -328,15 +282,6 @@ jobs:
|
||||
granular-rerun: 'false'
|
||||
test-args: >-
|
||||
:only-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Cleanup python-runner
|
||||
if: always()
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -382,15 +327,6 @@ jobs:
|
||||
granular-rerun: 'false'
|
||||
test-args: >-
|
||||
:only-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Cleanup python-runner
|
||||
if: always()
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
@@ -434,15 +370,6 @@ jobs:
|
||||
granular-rerun: 'false'
|
||||
test-args: >-
|
||||
:only-tags [:mb/transforms-python-test]
|
||||
- name: Upload Test Results
|
||||
uses: ./.github/actions/upload-test-results
|
||||
if: always()
|
||||
with:
|
||||
input-path: ./target/junit/
|
||||
output-name: ${{ github.job }}
|
||||
bucket: ${{ vars.AWS_S3_TEST_RESULTS_BUCKET }}
|
||||
role-to-assume: ${{ secrets.CI_TEST_RESULTS_IAM_ROLE }}
|
||||
aws-region: ${{ vars.AWS_REGION }}
|
||||
- name: Cleanup python-runner
|
||||
if: always()
|
||||
uses: ./.github/actions/cleanup-python-runner
|
||||
|
||||
20
.github/workflows/whitespace.yml
vendored
20
.github/workflows/whitespace.yml
vendored
@@ -1,20 +0,0 @@
|
||||
name: Whitespace
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'release-**'
|
||||
pull_request:
|
||||
merge_group:
|
||||
|
||||
jobs:
|
||||
whitespace-linter:
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Prepare back-end environment
|
||||
uses: ./.github/actions/prepare-backend
|
||||
- run: clojure -T:whitespace-linter lint :whitespace-linter/progress-bar false
|
||||
name: Run Whitespace Linter
|
||||
35
.github/workflows/yaml.yml
vendored
35
.github/workflows/yaml.yml
vendored
@@ -1,35 +0,0 @@
|
||||
name: YAML
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'release-**'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
files-changed:
|
||||
name: Check which files changed
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
yaml: ${{ steps.changes.outputs.yaml }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Test which files changed
|
||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
filters: .github/file-paths.yaml
|
||||
|
||||
yaml-linter:
|
||||
needs: files-changed
|
||||
runs-on: ${{ vars.DEFAULT_RUNNER_KEY }}
|
||||
if: needs.files-changed.outputs.yaml == 'true'
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Prepare front-end environment
|
||||
uses: ./.github/actions/prepare-frontend
|
||||
- run: bun run lint-yaml
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
@@ -223,6 +223,7 @@ Metabase's reference documentation.
|
||||
- [SSO](./embedding/modular-embedding.md)
|
||||
- [Guest](./embedding/guest-embedding.md)
|
||||
- [SDK](./embedding/sdk/introduction.md)
|
||||
- [Custom visualizations](./embedding/custom-visualizations.md)
|
||||
- [Full app embedding](./embedding/full-app-embedding.md)
|
||||
- [Securing embeds](./embedding/securing-embeds.md)
|
||||
- [AI agent resources](./embedding/ai-agent-resources.md)
|
||||
|
||||
6364
docs/api.json
generated
6364
docs/api.json
generated
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,7 @@ These policies determine how long cached results will remain valid.
|
||||
|
||||
{% include plans-blockquote.html feature="Duration caching policy" %}
|
||||
|
||||
Invalidate and clear the cache after a certain number of hours. When someone runs a query, Metabase will first check whether it has cached the results, and whether those results are still valid. If not, Metabase runs the query against the database and caches the results. These cached results will remain valid for the duration you've set.
|
||||
Invalidate and clear the cache after a certain amount of time (seconds, minutes, hours, or days). When someone runs a query, Metabase will first check whether it has cached the results, and whether those results are still valid. If not, Metabase runs the query against the database and caches the results. These cached results will remain valid for the duration you've set.
|
||||
|
||||
### Schedule caching policy
|
||||
|
||||
|
||||
@@ -222,6 +222,8 @@ config:
|
||||
llm-metabot-provider: anthropic/claude-sonnet-4-6
|
||||
llm-mistral-api-base-url: https://api.mistral.ai/v1
|
||||
llm-mistral-api-key: null
|
||||
llm-moonshot-api-base-url: https://api.moonshot.ai/v1
|
||||
llm-moonshot-api-key: null
|
||||
llm-openai-api-base-url: https://api.openai.com
|
||||
llm-openai-api-key: null
|
||||
llm-openai-model: gpt-5.4
|
||||
@@ -353,4 +355,5 @@ config:
|
||||
usage-metadata-schedule: 0 0 2 * * ? *
|
||||
use-native-pivot-tables: false
|
||||
user-visibility: all
|
||||
warehouse-allowed-networks: null
|
||||
```
|
||||
|
||||
@@ -1390,7 +1390,7 @@ Maximum tokens for LLM responses.
|
||||
- Default: `anthropic/claude-sonnet-4-6`
|
||||
- [Configuration file name](./config-file.md): `llm-metabot-provider`
|
||||
|
||||
The AI provider and model for Metabot. Format: provider/model-name, e.g. `anthropic/claude-haiku-4-5`, `openai/gpt-5.4`, `openrouter/anthropic/claude-haiku-4.5`.
|
||||
The AI provider and model for Metabot. Format: provider/model-name, e.g. `anthropic/claude-haiku-4-5`, `openai/gpt-5.4`, `moonshot/kimi-k3`, `openrouter/anthropic/claude-haiku-4.5`.
|
||||
|
||||
### `MB_LLM_MISTRAL_API_BASE_URL`
|
||||
|
||||
@@ -1408,6 +1408,22 @@ The Mistral API base URL used for Chat Completions.
|
||||
|
||||
The Mistral API Key.
|
||||
|
||||
### `MB_LLM_MOONSHOT_API_BASE_URL`
|
||||
|
||||
- Type: string
|
||||
- Default: `https://api.moonshot.ai/v1`
|
||||
- [Configuration file name](./config-file.md): `llm-moonshot-api-base-url`
|
||||
|
||||
The Moonshot AI API base URL used for Chat Completions. Repoint this to use the `.cn` platform; keys are not interchangeable between the two.
|
||||
|
||||
### `MB_LLM_MOONSHOT_API_KEY`
|
||||
|
||||
- Type: string
|
||||
- Default: `null`
|
||||
- [Configuration file name](./config-file.md): `llm-moonshot-api-key`
|
||||
|
||||
The Moonshot AI API Key.
|
||||
|
||||
### `MB_LLM_OPENAI_API_BASE_URL`
|
||||
|
||||
- Type: string
|
||||
@@ -2667,6 +2683,19 @@ When enabled, run pivot queries as a single native GROUPING SETS query on driver
|
||||
|
||||
Note: Sandboxed users will never see suggestions.
|
||||
|
||||
### `MB_WAREHOUSE_ALLOWED_NETWORKS`
|
||||
|
||||
- Type: keyword
|
||||
- Default: `null`
|
||||
|
||||
Controls which networks Metabase may connect to for warehouse connections.
|
||||
Options:
|
||||
- external-only (only globally routable public addresses)
|
||||
- allow-private (external + private networks but NOT loopback or link-local)
|
||||
- allow-all (no restrictions).
|
||||
Defaults to external-only on Metabase Cloud and allow-all when self-hosted.
|
||||
Also covers the SSH tunnel host and the database auth-provider URLs.
|
||||
|
||||
## Other environment variables
|
||||
|
||||
The following environment variables can only be set via the environment. They cannot be set by the configuration file.
|
||||
|
||||
@@ -43,7 +43,6 @@ For Slack subscriptions, you can set up a subscription for a channel (like #gene
|
||||
|
||||
You can also send subscriptions to private channels, but that requires additional configuration, see [Sending alerts and subscriptions to private Slack channels](../configuring-metabase/slack.md#sending-alerts-and-subscriptions-to-private-slack-channels).
|
||||
|
||||
|
||||
If you rename the subscribed channel in Slack, you'll need to update the subscription to point to the new Slack channel name.
|
||||
|
||||
### Frequency and timing
|
||||
@@ -134,7 +133,7 @@ You can view a list of all alerts and dashboard subscriptions that people have s
|
||||
|
||||
{% include plans-blockquote.html feature="Dashboard subscription filter customization" %}
|
||||
|
||||
You can customize which filter values to apply to each dashboard subscription. That way you can send an email (or Slack message) with the dashboard results to different groups of people, with different dashboard filters applied for each group. You only need to maintain one dashboard, which you can use to send results relevant to each subscriber. For example, send a dashboard filtered for "Marketing" department and "This week" date weekly to a marketing lead, and send the dashboard filtered for all departments and "This quarter" to the CEO.
|
||||
You can customize which filter values to apply to each dashboard subscription. That way you can send an email (or Slack message) with the dashboard results to different groups of people, with different dashboard filters applied for each group. You only need to maintain one dashboard, which you can use to send results relevant to each subscriber. For example, send a dashboard filtered for "Marketing" department and "This week" date weekly to a marketing lead, and send the dashboard filtered for all departments and "This quarter" to the CEO.
|
||||
|
||||
### Setting filter values
|
||||
|
||||
@@ -158,9 +157,14 @@ See [Remove Metabase branding from exports](../questions/exporting-results.md#re
|
||||
|
||||
Since people viewing embedded dashboards likely don't have direct access to your Metabase, Metabase will omit the links to Metabase items in subscriptions sent from embedded dashboards. That way people don't get broken links in their email. See [embedded dashboards](../embedding/components.md).
|
||||
|
||||
## Custom visualizations fall back to the default chart
|
||||
|
||||
Nobody's signed in when Metabase renders a subscription, so a card that uses a [custom visualization](../questions/visualizations/custom.md) falls back to the default visualization for its results.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Alerts](../questions/alerts.md)
|
||||
- [Setting up email](../configuring-metabase/email.md)
|
||||
- [Setting up Slack](../configuring-metabase/slack.md)
|
||||
- [Usage analytics](../usage-and-performance-tools/usage-analytics.md)
|
||||
- [Custom visualizations](../questions/visualizations/custom.md)
|
||||
|
||||
@@ -420,9 +420,9 @@ For uploading and managing plugins, see [Custom visualizations](../questions/vis
|
||||
|
||||
## Versioning and compatibility
|
||||
|
||||
The Custom Visualizations SDK works with Metabase 1.62 and newer. Declare the versions your plugin supports with `metabase.version` in `metabase-plugin.json`, using [npm semver range](https://github.com/npm/node-semver#ranges) syntax — for example, `">=1.62 <1.64"`. Keep the range closed on both ends: an open-ended range like `">=1.62.0"` claims compatibility with future Metabase versions your plugin has never run against. Write the range against the full version number (`">=1.62 <1.64"`), not a bare major version (`">=62 <64"`), which won't match.
|
||||
Metabase runs two compatibility checks on a plugin, and neither one blocks anything (the plugin uploads and runs either way). When a check fails, Metabase shows a [compatibility warning](../questions/visualizations/custom.md#compatibility-warnings-are-heads-ups-not-errors) on the **Manage visualizations** page.
|
||||
|
||||
If a plugin's declared range doesn't include the Metabase version it's installed on — at upload time, or later after a Metabase upgrade — the plugin still uploads and runs, and Metabase shows a warning on the **Manage visualizations** page.
|
||||
The second check is the Metabase version, which you declare yourself. Set `metabase.version` in `metabase-plugin.json` to an [npm semver range](https://github.com/npm/node-semver#ranges) like `">=1.62 <1.64"`, closed on both ends. Metabase versions are [license.major.minor](https://www.metabase.com/version-support), so include the license prefix: a bare `">=62 <64"` won't match anything.
|
||||
|
||||
## Custom visualization limitations
|
||||
|
||||
@@ -437,15 +437,15 @@ Metabase runs plugin code in an isolated sandbox, so a visualization works only
|
||||
- **Navigation and the rest of the app**: history changes, the host page's URL and referrer, and any DOM outside the plugin's own container.
|
||||
- **Unsafe DOM and timing APIs**: `document.write`, `execCommand`, constructable stylesheets, raw HTML parsers (`DOMParser`, `setHTMLUnsafe`, `XSLTProcessor`), and resource-timing APIs that expose other requests the page has made.
|
||||
|
||||
### When embedding, custom visualizations are only available in the SDK
|
||||
### Custom visualizations only work in authenticated embeds
|
||||
|
||||
The [Modular embedding SDK](../embedding/sdk/introduction.md) renders custom visualizations when you allowlist them with the [`allowedCustomVisualizations` prop](../embedding/sdk/config.md#custom-visualizations) on `MetabaseProvider`.
|
||||
[Modular embeds](../embedding/modular-embedding.md) can render custom visualizations when you allowlist them with the `allowedCustomVisualizations` setting, both with web components and with the [React SDK](../embedding/sdk/introduction.md). See [Custom visualizations in embeds](../embedding/custom-visualizations.md).
|
||||
|
||||
Other embedding types don't render custom visualizations. In [modular embedding](../embedding/modular-embedding.md) with web components and [public links](../embedding/public-links.md), any card that uses a custom visualization falls back to the default visualization for the query's results.
|
||||
In [guest embeds](../embedding/introduction.md) and [public links](../embedding/public-links.md), any question that uses a custom visualization falls back to the default visualization for the query's results.
|
||||
|
||||
### Custom visualizations don't render in exports and subscriptions
|
||||
|
||||
Static renders—like charts in alerts or dashboard subscriptions—fall back to a default visualization for the card's data shape.
|
||||
Static renders (like charts in alerts or dashboard subscriptions) fall back to a default visualization for the query's data shape.
|
||||
|
||||
## Example plugins
|
||||
|
||||
|
||||
@@ -106,4 +106,5 @@ If you're using the [modular embedding SDK](./sdk/introduction.md), you can prov
|
||||
## Further reading
|
||||
|
||||
- [Appearance](./appearance.md)
|
||||
- [Custom visualizations in embeds](./custom-visualizations.md)
|
||||
- [Modular embedding SDK](./sdk/introduction.md).
|
||||
|
||||
95
docs/embedding/custom-visualizations.md
Normal file
95
docs/embedding/custom-visualizations.md
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
title: Custom visualizations in embeds
|
||||
summary: Enable custom visualizations in your embeds by adding them to your allowlist.
|
||||
---
|
||||
|
||||
# Custom visualizations in embeds
|
||||
|
||||
{% include plans-blockquote.html feature="Custom visualizations" %}
|
||||
|
||||
Authenticated [modular embeds](./modular-embedding.md) can render [custom visualizations](../questions/visualizations/custom.md), whether you're embedding with web components or with the [React SDK](./sdk/introduction.md).
|
||||
|
||||
- [An admin has to turn on custom visualizations first](#an-admin-has-to-turn-on-custom-visualizations-first)
|
||||
- [Add custom visualizations to your allowlist](#add-custom-visualizations-to-your-allowlist)
|
||||
- [Set a Content Security Policy in your app](#set-a-content-security-policy-in-your-app)
|
||||
- [Custom visualizations don't work in guest embeds](#custom-visualizations-dont-work-in-guest-embeds)
|
||||
|
||||
## An admin has to turn on custom visualizations first
|
||||
|
||||
Before an embed can render a custom visualization, an admin needs to [turn on custom visualizations](../questions/visualizations/custom.md#enabling-custom-visualizations) in your Metabase and upload the visualization. If the visualization doesn't exist in your Metabase, allowlisting its name in your app won't do anything.
|
||||
|
||||
To build a custom visualization, check out [Building custom visualizations](../developers-guide/custom-visualizations.md).
|
||||
|
||||
## Add custom visualizations to your allowlist
|
||||
|
||||
Embeds don't load custom visualizations by default. You have to list each visualization you want in the `allowedCustomVisualizations` allowlist. Any question that uses a custom visualization that isn't on the allowlist will fall back to the default visualization for that query's results.
|
||||
|
||||
Each entry in the allowlist is the custom visualization's name, prefixed with `custom:`. A custom visualization named `Calendar Heatmap` becomes `"custom:Calendar Heatmap"`. The name comes from the `name` in the visualization's plugin manifest. You can look it up in your Metabase under **Admin** > **Settings** > **Custom visualizations** > **Manage visualizations**.
|
||||
|
||||
Names are case-sensitive, so `"custom:calendar heatmap"` won't match a visualization named `Calendar Heatmap`. If an entry on your allowlist doesn't match an uploaded visualization, Metabase logs a warning to the browser console.
|
||||
|
||||
Where the allowlist goes depends on how you're embedding:
|
||||
|
||||
- [Web components](#web-components-allowlist-for-custom-visualizations)
|
||||
- [React SDK](#react-sdk-allowlist-for-custom-visualizations)
|
||||
|
||||
### Web components allowlist for custom visualizations
|
||||
|
||||
`allowedCustomVisualizations` is a [page-level config](./modular-embedding.md#page-level-config), not an attribute on `<metabase-dashboard>` or `<metabase-question>`. The allowlist applies to every component on the page.
|
||||
|
||||
Add `allowedCustomVisualizations` to `defineMetabaseConfig()`:
|
||||
|
||||
```html
|
||||
<!-- Load embedding library -->
|
||||
<!-- REPLACE WITH YOUR METABASE URL HERE -->
|
||||
<script defer src="https://your-metabase-url/app/embed.js"></script>
|
||||
|
||||
<!-- Embedding configuration -->
|
||||
<script>
|
||||
function defineMetabaseConfig(config) {
|
||||
window.metabaseConfig = config;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
defineMetabaseConfig({
|
||||
instanceUrl: "https://your-metabase-url",
|
||||
allowedCustomVisualizations: ["custom:Calendar Heatmap", "custom:Thumbs"],
|
||||
});
|
||||
</script>
|
||||
|
||||
<metabase-dashboard dashboard-id="1"></metabase-dashboard>
|
||||
```
|
||||
|
||||
If you create your embed through the [embed wizard](./modular-embedding.md#create-a-new-embed), Metabase fills in the allowlist with the custom visualizations that the dashboard or question you picked already uses, so the generated snippet works as-is.
|
||||
|
||||
### React SDK allowlist for custom visualizations
|
||||
|
||||
Pass the `allowedCustomVisualizations` prop to `MetabaseProvider`. Like the page-level config for web components, the allowlist is global: it applies to every embedded component under the provider, not to one question or dashboard.
|
||||
|
||||
```typescript
|
||||
{% include_file "{{ dirname }}/sdk/snippets/config/config-with-custom-visualizations.tsx" snippet="example" %}
|
||||
```
|
||||
|
||||
## Set a Content Security Policy in your app
|
||||
|
||||
A custom visualization runs third-party JavaScript in your app. Metabase runs that code in an isolated sandbox, so a visualization can't reach the rest of your app or make network requests. The sandbox doesn't block passive image loads, though: a visualization can still trigger outbound requests through `<img>` tags or CSS `url()`.
|
||||
|
||||
To limit where custom visualizations can load images from, set a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) with an `img-src` allowlist in your app. The core Metabase app sets this CSP with [Restrict image domains](../configuring-metabase/settings.md#restrict-image-domains), but you should also set a CSP in your app.
|
||||
|
||||
## Custom visualizations don't work in guest embeds
|
||||
|
||||
Custom visualizations only work when Metabase knows who's viewing the embed. [Guest embeds](./guest-embedding.md) ignore `allowedCustomVisualizations` (and log a warning to the console), and fall back to the default visualization.
|
||||
|
||||
Any embed with a signed-in person can load custom visualizations, including embeds you're previewing locally with an API key or your existing Metabase session. See [Authentication](./authentication.md).
|
||||
|
||||
### Public links, subscriptions, and alerts fall back to the default visualization
|
||||
|
||||
Nobody signs in to view a [public link](./public-links.md), and nobody's signed in when Metabase renders a [dashboard subscription or alert](../questions/alerts.md), so a question that uses a custom visualization falls back to the default visualization for its results in both cases.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Custom visualizations](../questions/visualizations/custom.md)
|
||||
- [Building custom visualizations](../developers-guide/custom-visualizations.md)
|
||||
- [Modular embedding](./modular-embedding.md)
|
||||
- [Modular embedding components](./components.md)
|
||||
@@ -533,6 +533,7 @@ Guest embeds can't take advantage of:
|
||||
- [Usage analytics](../usage-and-performance-tools/usage-analytics.md)
|
||||
- [Query builder](../questions/query-builder/editor.md)
|
||||
- [AI chat](./sdk/ai-chat.md)
|
||||
- [Custom visualizations](./custom-visualizations.md)
|
||||
|
||||
For those features, check out [Modular embedding with SSO](./modular-embedding.md).
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ All SSO options require a Pro or Enterprise plan.
|
||||
| [Collection browser](./sdk/collections.md) | ✅ | ❌ |
|
||||
| Advanced [Tenant](./tenants.md) and [permissions](../permissions/embedding.md) management | ✅ | ❌ |
|
||||
| [Advanced theming](./appearance.md) | ✅ | ❌ |
|
||||
| [Custom visualizations](./custom-visualizations.md) | ✅ | ❌ |
|
||||
| [Usage analytics](../usage-and-performance-tools/usage-analytics.md) | ✅ | ❌ |
|
||||
| Customize layouts and behavior with [plugins](./sdk/plugins.md) | ✅ | ❌ |
|
||||
| [Locked filters](./guest-embedding.md#locked-parameters)\*\*\* | ❌ | ✅ |
|
||||
@@ -106,7 +107,7 @@ For information about the anonymous usage data Metabase collects from embedded c
|
||||
## Embedding limitations
|
||||
|
||||
- Currently, you can't embed [documents](../documents/introduction.md) (though you can create [public documents](./public-links.md)).
|
||||
- Only the [Modular embedding SDK](./sdk/introduction.md) renders [custom visualizations](../questions/visualizations/custom.md), and only ones you allowlist with the [`allowedCustomVisualizations` prop](./sdk/config.md#custom-visualizations). In other embedding types, any card that uses a custom visualization falls back to the default visualization for the query's results.
|
||||
- [Modular embeds](./modular-embedding.md) that use SSO can render [custom visualizations](../questions/visualizations/custom.md), but only the custom visualizations you add to your [allowlist](./custom-visualizations.md). Guest embeds fall back to the default visualization.
|
||||
|
||||
## Further reading
|
||||
|
||||
|
||||
@@ -196,6 +196,8 @@ To define the configuration that applies to every embed on the page, use the `de
|
||||
|
||||
- `pluginsConfig` : plugins to customize the behavior of embedded components. Use the `handleLink` function to customize what happens when people click a link in your embedded questions and dashboards. For details on the `handleLink` API, including code examples, see [`handleLink` plugin](./sdk/plugins.md#handlelink).
|
||||
|
||||
- `allowedCustomVisualizations: ["custom:Calendar Heatmap"]` (optional): the [custom visualizations](./custom-visualizations.md) that the components on the page are allowed to load. Not available in guest embeds.
|
||||
|
||||
## Authentication
|
||||
|
||||
For authentication setup including API keys for local testing, SSO with JWT/SAML, and cross-domain configuration, see [Modular embedding - authentication](./authentication.md).
|
||||
|
||||
@@ -94,6 +94,8 @@ If you want to embed your question or dashboard as an iframe in a simple web pag
|
||||
|
||||
To customize the appearance of your question or dashboard, you can update the link in the `src` attribute with [public embed parameters](#public-embed-parameters).
|
||||
|
||||
Nobody signs in to view a public link or public embed, so a question that uses a [custom visualization](../questions/visualizations/custom.md) falls back to the default visualization for its results. To render custom visualizations, you'll need an authenticated [modular embed](./modular-embedding.md) with the visualization on your [allowlist](./custom-visualizations.md).
|
||||
|
||||
## Public embed parameters
|
||||
|
||||
To apply appearance or filter settings to your public embed, you can add parameters to the end of the link in your iframe's `src` attribute.
|
||||
@@ -195,3 +197,4 @@ Admins can see all publicly shared questions, dashboards, documents, and actions
|
||||
- [Publishing data visualizations to the web](https://www.metabase.com/learn/metabase-basics/embedding/charts-and-dashboards).
|
||||
- [Customizing Metabase's appearance](../configuring-metabase/appearance.md).
|
||||
- [Embedding introduction](../embedding/start.md).
|
||||
- [Custom visualizations in embeds](./custom-visualizations.md).
|
||||
|
||||
@@ -34,18 +34,7 @@ To pass a theme, use `defineMetabaseTheme`. See [Reuse a saved theme in the SDK]
|
||||
|
||||
## Custom visualizations
|
||||
|
||||
The SDK can render [custom visualizations](../../questions/visualizations/custom.md). To allow custom visualizations in the embed, pass an allowlist of the custom visualizations to the `allowedCustomVisualizations` prop on `MetabaseProvider`:
|
||||
|
||||
```typescript
|
||||
{% include_file "{{ dirname }}/snippets/config/config-with-custom-visualizations.tsx" snippet="example" %}
|
||||
```
|
||||
|
||||
Only the custom visualizations you list will load. Each entry is the visualization's name (the manifest `name`, which you can find under **Admin** > **Settings** > **Custom visualizations** > **Manage visualizations**), prefixed with `custom:`. For example, a custom visualization named `Calendar Heatmap` becomes `"custom:Calendar Heatmap"`. Names are case-sensitive, so `"custom:calendar heatmap"` won't match a visualization named `Calendar Heatmap`.
|
||||
|
||||
Omitting the prop, or passing an empty array, turns off custom visualizations. Cards that use a custom visualization will fall back to the default visualization for the query's results. If you allowlist a name that doesn't match an installed custom visualization, the SDK logs a warning to the console and falls back to the default visualization.
|
||||
|
||||
|
||||
For security, the SDK runs each custom visualization's code in an isolated sandbox, so a visualization can't reach your app or make network requests. The sandbox doesn't block passive image loads, though. A visualization can still trigger outbound requests through `<img>` tags or CSS `url()`. To limit where custom visualizations can load images from, set a Content Security Policy with an `img-src` allowlist in your app (the core Metabase app does this with [Restrict image domains](../../configuring-metabase/settings.md#restrict-image-domains)). [Only add visualizations you trust](../../questions/visualizations/custom.md#only-add-visualizations-you-trust).
|
||||
The SDK can render [custom visualizations](../../questions/visualizations/custom.md) that you allowlist with the `allowedCustomVisualizations` prop on `MetabaseProvider`. See [Custom visualizations in embeds](../custom-visualizations.md).
|
||||
|
||||
## Global event handlers
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ Start with one of the quickstarts, then see these pages for more info on compone
|
||||
- [Actions](./actions.md)
|
||||
- [Appearance](../appearance.md)
|
||||
- [Collections](./collections.md)
|
||||
- [Custom visualizations](../custom-visualizations.md)
|
||||
- [Plugins](./plugins.md)
|
||||
- [Config](./config.md)
|
||||
- [Versioning](./version.md)
|
||||
|
||||
@@ -42,6 +42,10 @@ Every `<metabase-question>` attribute, the `StaticQuestion` and `InteractiveQues
|
||||
|
||||
Guest embedding is a secure way to embed charts and dashboards. Guest embeds are view-only; people won't be able to drill-through charts and tables.
|
||||
|
||||
### [Custom visualizations in embeds](./custom-visualizations.md)
|
||||
|
||||
Allowlist the custom visualizations that your embedded components can load. Available in modular embeds that use SSO, with web components or the React SDK.
|
||||
|
||||
### [Translating embeds](./translations.md)
|
||||
|
||||
Upload a translation dictionary to translate questions and dashboards in modular embeds.
|
||||
|
||||
@@ -162,9 +162,14 @@ See [Remove Metabase branding from exports](./exporting-results.md#remove-metaba
|
||||
|
||||
Since people viewing embedded questions likely don't have direct access to your Metabase, Metabase omits links to Metabase items in alerts sent from embedded questions. That way people don't get broken links in their email.
|
||||
|
||||
## Custom visualizations fall back to the default chart
|
||||
|
||||
Nobody's signed in when Metabase renders an alert, so a question that uses a [custom visualization](./visualizations/custom.md) falls back to the default visualization for its results.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Dashboard subscriptions](../dashboards/subscriptions.md)
|
||||
- [Setting up email](../configuring-metabase/email.md)
|
||||
- [Setting up Slack](../configuring-metabase/slack.md)
|
||||
- [Usage analytics](../usage-and-performance-tools/usage-analytics.md)
|
||||
- [Custom visualizations](./visualizations/custom.md)
|
||||
|
||||
@@ -38,7 +38,7 @@ Once you've [built the custom visualization](../../developers-guide/custom-visua
|
||||
3. Click **Add visualization**.
|
||||
|
||||
- Bundles must be smaller than 5 MiB.
|
||||
- Each custom visualization lists the Metabase versions it supports (for example, "Requires Metabase >=1.62"). If your Metabase version isn't in that range, Metabase rejects the upload and tells you which version the visualization needs.
|
||||
- Each custom visualization lists the Metabase versions it supports (for example, "Requires Metabase >=1.62 <1.64"). If your Metabase isn't in that range, the visualization still uploads and works, but Metabase adds a [compatibility warning](#compatibility-warnings-are-heads-ups-not-errors).
|
||||
- The **Manage visualizations** page shows each custom visualization's icon, name, the first eight characters of the bundle's hash, and its required Metabase version range, so you can tell which version is installed.
|
||||
|
||||
## Using a custom visualization
|
||||
@@ -55,11 +55,11 @@ Custom visualizations behave like built-in charts in most places:
|
||||
- **Dark mode.** Custom visualizations that use Metabase's colors adapt to [dark mode](../../people-and-groups/account-settings.md#theme) automatically.
|
||||
- **Icons.** A custom visualization shows its own icon in the visualization picker, and questions that use it show that icon in collections and bookmarks.
|
||||
|
||||
### Custom visualizations in embeds only work when using the SDK
|
||||
### Custom visualizations in embeds only work when people are signed in
|
||||
|
||||
The [Modular embedding SDK](../../embedding/sdk/introduction.md) can render custom visualizations. You allowlist the custom visualizations you want to load with the [`allowedCustomVisualizations` prop](../../embedding/sdk/config.md#custom-visualizations) on `MetabaseProvider`.
|
||||
[Modular embeds](../../embedding/modular-embedding.md) that use SSO can render custom visualizations, whether you embed with web components or with the [React SDK](../../embedding/sdk/introduction.md). You allowlist the custom visualizations you want to load with the `allowedCustomVisualizations` setting. See [Custom visualizations in embeds](../../embedding/custom-visualizations.md).
|
||||
|
||||
Other embedding types don't render custom visualizations. In [modular embedding](../../embedding/modular-embedding.md) with web components, [guest and static embeds](../../embedding/introduction.md), and [public links](../../embedding/public-links.md), any card that uses a custom visualization falls back to the default visualization for the query's results.
|
||||
Embeds without an authenticated person don't render custom visualizations. In [guest and static embeds](../../embedding/introduction.md) and [public links](../../embedding/public-links.md), any card that uses a custom visualization falls back to the default visualization for the query's results.
|
||||
|
||||
## Managing custom visualizations
|
||||
|
||||
@@ -69,6 +69,13 @@ _Admin > Settings > Custom visualizations > Manage visualizations_
|
||||
- **Replace a bundle.** Upload a new `.tgz` to ship an updated version of a custom visualization. The new bundle's manifest `name` _must_ match the existing visualization's identifier, so questions that already use the visualization keep working.
|
||||
- **Remove a visualization.** Cards that used the custom viz fall back to the default visualization.
|
||||
|
||||
### Compatibility warnings are heads-ups, not errors
|
||||
|
||||
Metabase flags a custom visualization on the **Manage visualizations** page when:
|
||||
|
||||
- **The SDK version doesn't match.** The visualization was built with a version of the Custom Visualizations SDK that your Metabase wasn't tested against.
|
||||
- **The Metabase version doesn't match.** A visualization that was in range when you uploaded it can fall out of range when you upgrade Metabase, so this warning can show up on a visualization that's been working for months.
|
||||
|
||||
## Exports
|
||||
|
||||
- **Dashboard subscriptions and alerts don't use custom visualizations**. Cards that use custom visualizations will fall back to a default visualization for the card's data shape.
|
||||
|
||||
BIN
docs/troubleshooting-guide/images/model-icon.png
Normal file
BIN
docs/troubleshooting-guide/images/model-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@@ -67,7 +67,7 @@ If you can’t solve your problem using the troubleshooting guides:
|
||||
[known-issues-models]: https://github.com/metabase/metabase/labels/Querying%2FModels
|
||||
[limit-data-learn]: https://www.metabase.com/learn/metabase-basics/administration/administration-and-operation/making-dashboards-faster#ask-for-less-data
|
||||
[materialize-views-learn]: https://www.metabase.com/learn/metabase-basics/administration/administration-and-operation/making-dashboards-faster#materialize-views-create-new-tables-to-store-query-results
|
||||
[model-button-image]: https://www.metabase.com/learn/images/models/model-icon.png
|
||||
[model-button-image]: ./images/model-icon.png
|
||||
[model-docs]: ../data-modeling/models.md
|
||||
[replicate-database-learn]: https://www.metabase.com/learn/metabase-basics/administration/administration-and-operation/making-dashboards-faster#replicate-your-database
|
||||
[sql-explain-learn]: https://www.metabase.com/learn/sql/working-with-sql/sql-best-practices#explain
|
||||
|
||||
@@ -63,16 +63,17 @@ export const getUnpinnedSection = () => {
|
||||
export const openPinnedItemMenu = (name: string) => {
|
||||
cy.log(`open pinned item menu: ${name}`);
|
||||
|
||||
// Hovering a pinned item can trigger an async re-render (e.g. a pinned
|
||||
// metric runs its query), which detaches a chained `.within()` subject. Hover
|
||||
// first, then re-query the actions button so the click acts on a settled
|
||||
// element and Cypress can retry on detach.
|
||||
// Hovering a pinned item can trigger an async re-render, which replaces the
|
||||
// hovered node and drops its :hover state, hiding the actions button again.
|
||||
// Hover first so the button settles, then realClick: the real pointer
|
||||
// movement re-applies :hover to the current node, so the click cannot race
|
||||
// the visibility toggle.
|
||||
getPinnedSection().findByText(name).closest("a").realHover();
|
||||
getPinnedSection()
|
||||
.findByText(name)
|
||||
.closest("a")
|
||||
.findByLabelText("Actions")
|
||||
.click();
|
||||
.realClick();
|
||||
};
|
||||
|
||||
export const openUnpinnedItemMenu = (name: string) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -209,7 +209,7 @@ describe("scenarios > admin > performance > caching", () => {
|
||||
H.visitQuestion(ORDERS_QUESTION_ID);
|
||||
openSidebarCacheStrategyForm("question");
|
||||
H.selectCacheStrategy(/Duration/);
|
||||
cy.findByLabelText(/Cache duration/).type("99");
|
||||
cy.findByRole("spinbutton", { name: /Cache duration/ }).type("99");
|
||||
saveCacheStrategyForm();
|
||||
|
||||
cy.log("Configure Orders, Count with Adaptive");
|
||||
@@ -227,7 +227,10 @@ describe("scenarios > admin > performance > caching", () => {
|
||||
cy.log("Clicking Duration: 99h opens its form with duration selected");
|
||||
cy.findByTestId("cache-config-table").contains("Duration: 99h").click();
|
||||
H.cacheStrategySelect().should("have.value", "Duration");
|
||||
cy.findByLabelText(/Cache duration/).should("have.value", "99");
|
||||
cy.findByRole("spinbutton", { name: /Cache duration/ }).should(
|
||||
"have.value",
|
||||
"99",
|
||||
);
|
||||
|
||||
cy.log("Close the sidesheet via ESC");
|
||||
cy.get("body").type("{esc}");
|
||||
|
||||
@@ -55,13 +55,18 @@ describe("scenarios > collection pinned items overview", () => {
|
||||
|
||||
cy.intercept("POST", "/api/card/**/query").as("getCardQuery");
|
||||
cy.intercept("GET", "/api/**/items?pinned_state*").as("getPinnedItems");
|
||||
cy.intercept("GET", "/api/**/items?models*").as("getCollectionItems");
|
||||
});
|
||||
|
||||
it("should be able to pin a dashboard", () => {
|
||||
it("should be able to pin a dashboard and keep it in the contents list", () => {
|
||||
openRootCollection();
|
||||
H.openUnpinnedItemMenu(DASHBOARD_NAME);
|
||||
H.popover().findByText("Pin this").click();
|
||||
cy.wait("@getPinnedItems");
|
||||
cy.wait(["@getPinnedItems", "@getCollectionItems"]);
|
||||
|
||||
cy.findByTestId("collection-table")
|
||||
.findByText(DASHBOARD_NAME)
|
||||
.should("be.visible");
|
||||
|
||||
H.getPinnedSection().within(() => {
|
||||
cy.icon("dashboard").should("be.visible");
|
||||
@@ -227,17 +232,19 @@ describe("scenarios > collection pinned items overview", () => {
|
||||
// on specific elements rather than truly simulating mouse movements across the screen
|
||||
H.dragAndDrop("draggingViz", "pinnedItems");
|
||||
|
||||
cy.findByTestId("collection-table")
|
||||
.findByText("Orders, Count, Grouped by Created At (year)")
|
||||
.should("not.exist");
|
||||
|
||||
cy.findByTestId("pinned-items")
|
||||
.findByText("Orders, Count, Grouped by Created At (year)")
|
||||
.should("exist");
|
||||
|
||||
// Pinned items stay in the contents list below the pinned section
|
||||
cy.wait("@getCollectionItems");
|
||||
cy.findByTestId("collection-table")
|
||||
.findByText("Orders, Count, Grouped by Created At (year)")
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
const openRootCollection = () => {
|
||||
cy.visit("/collection/root");
|
||||
cy.wait("@getPinnedItems");
|
||||
cy.wait(["@getPinnedItems", "@getCollectionItems"]);
|
||||
};
|
||||
|
||||
@@ -1133,6 +1133,173 @@ describe("scenarios > collection items listing", () => {
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
describe("search", () => {
|
||||
function interceptSearch(query, alias) {
|
||||
cy.intercept({
|
||||
method: "GET",
|
||||
pathname: "/api/collection/root/items",
|
||||
query: {
|
||||
q: query,
|
||||
},
|
||||
}).as(alias);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
archiveAll();
|
||||
|
||||
H.createCollection({
|
||||
name: "Quarterly revenue",
|
||||
});
|
||||
|
||||
cy.signIn("normal");
|
||||
H.createQuestion({
|
||||
name: "Customer health",
|
||||
collection_position: null,
|
||||
query: TEST_QUESTION_QUERY,
|
||||
});
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
|
||||
it("should search by item name and last editor name, clear results, and show an empty state", () => {
|
||||
visitRootCollection();
|
||||
|
||||
interceptSearch("revenue", "searchByName");
|
||||
cy.findByLabelText("Search items in this collection")
|
||||
.should("have.value", "")
|
||||
.type("revenue")
|
||||
.should("have.value", "revenue");
|
||||
cy.wait("@searchByName").then(({ request }) => {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
|
||||
expect(searchParams.get("q")).to.equal("revenue");
|
||||
expect(searchParams.get("offset")).to.equal("0");
|
||||
});
|
||||
|
||||
cy.findByTestId("collection-table")
|
||||
.should("contain", "Quarterly revenue")
|
||||
.and("not.contain", "Customer health");
|
||||
|
||||
cy.findByLabelText("Clear search").click();
|
||||
cy.findByLabelText("Search items in this collection").should(
|
||||
"have.value",
|
||||
"",
|
||||
);
|
||||
cy.findByTestId("collection-table")
|
||||
.should("contain", "Quarterly revenue")
|
||||
.and("contain", "Customer health");
|
||||
|
||||
interceptSearch("Robert", "searchByEditor");
|
||||
cy.findByLabelText("Search items in this collection")
|
||||
.type("Robert")
|
||||
.should("have.value", "Robert");
|
||||
cy.wait("@searchByEditor").then(({ request }) => {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
|
||||
expect(searchParams.get("q")).to.equal("Robert");
|
||||
expect(searchParams.get("offset")).to.equal("0");
|
||||
});
|
||||
|
||||
cy.findByTestId("collection-table")
|
||||
.should("contain", "Customer health")
|
||||
.and("not.contain", "Quarterly revenue");
|
||||
|
||||
cy.findByLabelText("Clear search").click();
|
||||
cy.findByLabelText("Search items in this collection").should(
|
||||
"have.value",
|
||||
"",
|
||||
);
|
||||
|
||||
interceptSearch("nothing matches this", "searchWithoutResults");
|
||||
cy.findByLabelText("Search items in this collection")
|
||||
.type("nothing matches this")
|
||||
.should("have.value", "nothing matches this");
|
||||
cy.wait("@searchWithoutResults").then(({ request }) => {
|
||||
expect(new URL(request.url).searchParams.get("q")).to.equal(
|
||||
"nothing matches this",
|
||||
);
|
||||
});
|
||||
|
||||
cy.findByTestId("collection-filter-empty-state").within(() => {
|
||||
cy.findByText("Didn't find anything").should("be.visible");
|
||||
cy.findByText("There weren't any results for your search.").should(
|
||||
"be.visible",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("type filters", () => {
|
||||
beforeEach(() => {
|
||||
archiveAll();
|
||||
|
||||
H.createDashboard({
|
||||
name: "Revenue dashboard",
|
||||
collection_position: null,
|
||||
});
|
||||
H.createQuestion({
|
||||
name: "Customer question",
|
||||
collection_position: null,
|
||||
query: TEST_QUESTION_QUERY,
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter collection items by the available types", () => {
|
||||
cy.intercept(
|
||||
{
|
||||
method: "GET",
|
||||
pathname: "/api/collection/root/items",
|
||||
},
|
||||
(request) => {
|
||||
const models = new URL(request.url).searchParams
|
||||
.getAll("models")
|
||||
.sort();
|
||||
|
||||
if (models.join(",") === "card,collection") {
|
||||
request.alias = "getTypeFilteredCollectionItems";
|
||||
}
|
||||
},
|
||||
);
|
||||
visitRootCollection();
|
||||
|
||||
cy.findByRole("button", { name: "Filter" }).click();
|
||||
H.popover().within(() => {
|
||||
cy.findAllByRole("checkbox").should("have.length", 3);
|
||||
cy.findByLabelText("Collection").should("be.checked");
|
||||
cy.findByLabelText("Dashboard").should("be.checked");
|
||||
cy.findByLabelText("Question").should("be.checked");
|
||||
cy.findByLabelText("Model").should("not.exist");
|
||||
cy.findByRole("button", { name: "Apply" }).should("not.exist");
|
||||
cy.findByLabelText("Dashboard").click();
|
||||
});
|
||||
|
||||
cy.wait("@getTypeFilteredCollectionItems").then(({ request }) => {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
|
||||
expect(searchParams.getAll("models").sort()).to.deep.equal([
|
||||
"card",
|
||||
"collection",
|
||||
]);
|
||||
expect(searchParams.get("offset")).to.equal("0");
|
||||
});
|
||||
cy.findByTestId("collection-table")
|
||||
.should("contain", "First collection")
|
||||
.and("contain", "Customer question")
|
||||
.and("not.contain", "Revenue dashboard");
|
||||
cy.findByTestId("type-filter-indicator")
|
||||
.find('[class*="Indicator-indicator"]')
|
||||
.should("exist");
|
||||
|
||||
H.popover().findByLabelText("Dashboard").click();
|
||||
|
||||
cy.findByTestId("collection-table")
|
||||
.should("contain", "Revenue dashboard")
|
||||
.and("contain", "Customer question");
|
||||
cy.findByTestId("type-filter-indicator")
|
||||
.find('[class*="Indicator-indicator"]')
|
||||
.should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pagination", () => {
|
||||
const SUBCOLLECTIONS = 1;
|
||||
const ADDED_QUESTIONS = 15;
|
||||
|
||||
@@ -31,7 +31,9 @@ describe("scenarios > Metabase Analytics Collection (AuditV2) ", () => {
|
||||
|
||||
H.openNavigationSidebar();
|
||||
cy.findByRole("link", { name: /Usage analytics/i }).click();
|
||||
cy.findByRole("link", { name: /Metabase metrics/i }).click();
|
||||
H.getPinnedSection()
|
||||
.findByRole("link", { name: /Metabase metrics/i })
|
||||
.click();
|
||||
cy.findByRole("link", { name: /Question views last week/i }).click();
|
||||
|
||||
cy.findByRole("button", { name: /Editor/ }).click();
|
||||
|
||||
@@ -68,17 +68,17 @@ describe("collection permissions", () => {
|
||||
cy.findByTestId("pinned-items").should("not.exist");
|
||||
|
||||
pinItem("Orders in a dashboard");
|
||||
cy.findByTestId("collection-table")
|
||||
cy.findByTestId("pinned-items")
|
||||
.findByText("Orders in a dashboard")
|
||||
.should("not.exist");
|
||||
.should("be.visible");
|
||||
|
||||
pinItem("Orders, Count");
|
||||
cy.findByTestId("collection-table")
|
||||
cy.findByTestId("pinned-items")
|
||||
.findByText("Orders, Count")
|
||||
.should("not.exist");
|
||||
.should("be.visible");
|
||||
|
||||
// Should see "pinned items" and items should be in that section
|
||||
cy.findByTestId("pinned-items").within(() => {
|
||||
// Pinned items also stay in the contents list
|
||||
cy.findByTestId("collection-table").within(() => {
|
||||
cy.findByText("Orders in a dashboard");
|
||||
cy.findByText("Orders, Count");
|
||||
});
|
||||
|
||||
@@ -404,12 +404,7 @@ describe("scenarios > collections > trash", () => {
|
||||
archiveBanner().should("not.exist");
|
||||
|
||||
cy.visit("/trash");
|
||||
|
||||
collectionTable().within(() => {
|
||||
cy.findByText("Collection A").should("not.exist");
|
||||
cy.findByText("Dashboard A").should("not.exist");
|
||||
cy.findByText("Question A").should("not.exist");
|
||||
});
|
||||
cy.findByTestId("collection-empty-state").should("be.visible");
|
||||
|
||||
cy.visit(`/collection/${FIRST_COLLECTION_ID}`);
|
||||
|
||||
@@ -494,9 +489,7 @@ describe("scenarios > collections > trash", () => {
|
||||
archiveBanner().findByText("Delete permanently").click();
|
||||
H.modal().findByText("Delete Question B permanently?").should("exist");
|
||||
H.modal().findByText("Delete permanently").click();
|
||||
collectionTable().within(() => {
|
||||
cy.findByText("Question B").should("not.exist");
|
||||
});
|
||||
cy.findByTestId("collection-empty-state").should("be.visible");
|
||||
});
|
||||
|
||||
describe("bulk actions", () => {
|
||||
|
||||
@@ -727,7 +727,7 @@ describe("scenarios > explorations > sidebar triage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("hides pages from the toolbar and group menus, reveals them via Show hidden items, and keeps Initial investigation when everything is hidden", () => {
|
||||
it("hides pages from the toolbar and group menus and reveals them via Show hidden items", () => {
|
||||
createTwoPageExploration("Sidebar hide fixture").then(
|
||||
({ explorationId, metricName, pageNames }) => {
|
||||
cy.intercept("PUT", "/api/exploration/pages/hidden").as(
|
||||
@@ -751,7 +751,6 @@ describe("scenarios > explorations > sidebar triage", () => {
|
||||
)!;
|
||||
|
||||
cy.log("Triage arrows step between pages");
|
||||
cy.findByRole("button", { name: "Previous" }).should("be.disabled");
|
||||
cy.findByRole("button", { name: "Next" }).click();
|
||||
selectedRows().should("contain.text", secondPageName);
|
||||
cy.findByRole("button", { name: "Previous" }).click();
|
||||
|
||||
@@ -55,9 +55,10 @@ describe("scenarios > metrics > collection", () => {
|
||||
H.getPinnedSection()
|
||||
.findByText(ORDERS_SCALAR_METRIC.name)
|
||||
.should("be.visible");
|
||||
// Pinned items also stay in the contents list below the pinned section
|
||||
H.getUnpinnedSection()
|
||||
.findByText(ORDERS_SCALAR_METRIC.name)
|
||||
.should("not.exist");
|
||||
.should("be.visible");
|
||||
H.openPinnedItemMenu(ORDERS_SCALAR_METRIC.name);
|
||||
H.popover().findByText("Unpin").click();
|
||||
H.getPinnedSection().should("not.exist");
|
||||
@@ -71,7 +72,7 @@ describe("scenarios > metrics > collection", () => {
|
||||
.should("be.visible");
|
||||
H.getUnpinnedSection()
|
||||
.findByText(ORDERS_SCALAR_METRIC.name)
|
||||
.should("not.exist");
|
||||
.should("be.visible");
|
||||
});
|
||||
|
||||
it("should be possible to add and remove a metric from bookmarks", () => {
|
||||
|
||||
@@ -195,40 +195,6 @@ describe("scenarios > metrics > editing", () => {
|
||||
verifyMetricAboutTimeseries({ yAxis: "Sum of Total" });
|
||||
});
|
||||
|
||||
it("should pin new metrics automatically", () => {
|
||||
cy.visit("/browse/metrics");
|
||||
cy.findByTestId("browse-metrics-header")
|
||||
.findByLabelText("Create a new metric")
|
||||
.should("be.visible")
|
||||
.click();
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "metric_create_started",
|
||||
triggered_from: "browse_metrics",
|
||||
});
|
||||
|
||||
H.MetricPage.queryEditor().should("be.visible");
|
||||
H.miniPicker().within(() => {
|
||||
cy.findByText("Sample Database").click();
|
||||
cy.findByText("Orders").click();
|
||||
});
|
||||
saveNewMetric({ name: "Pinned metric" });
|
||||
|
||||
H.expectUnstructuredSnowplowEvent({
|
||||
event: "metric_created",
|
||||
triggered_from: "main_app",
|
||||
result: "success",
|
||||
});
|
||||
|
||||
H.MetricPage.aboutPage().should("be.visible");
|
||||
H.MetricPage.header().findByText("Our analytics").click();
|
||||
|
||||
cy.findByTestId("pinned-items").within(() => {
|
||||
cy.findByText("Pinned metric").should("be.visible");
|
||||
cy.findByText("A metric").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not crash when cancelling creation or editing of a metric (metabase#48024)", () => {
|
||||
cy.log("cancel new metric creation");
|
||||
startNewMetricWithTable("Sample Database", "Orders");
|
||||
|
||||
@@ -36,8 +36,6 @@ describe("scenarios > models > create", () => {
|
||||
// After saving, we land on view mode for the model
|
||||
cy.location("pathname").should("match", /^\/model\/\d+-.*$/);
|
||||
cy.findByTestId("question-row-count").should("have.text", "Showing 1 row");
|
||||
|
||||
checkIfPinned(modelName);
|
||||
});
|
||||
|
||||
// This covers creating a GUI model from the browse page + nocollection permissions (2 in 1)
|
||||
@@ -105,15 +103,3 @@ function navigateToNewModelPage(queryType = "native") {
|
||||
cy.findByText("Use a native query").click();
|
||||
}
|
||||
}
|
||||
|
||||
function checkIfPinned(modelName) {
|
||||
cy.findByTestId("app-bar").findByText("Our analytics").click();
|
||||
cy.location("pathname").should("eq", "/collection/root");
|
||||
|
||||
cy.findByText(modelName)
|
||||
.closest("a")
|
||||
.find(".Icon-ellipsis")
|
||||
.click({ force: true });
|
||||
|
||||
H.popover().findByText("Unpin").should("be.visible");
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ describe("scenarios > models", () => {
|
||||
.findAllByText("Our analytics")
|
||||
.first()
|
||||
.click();
|
||||
getCollectionItemCard("Products Model").icon("model");
|
||||
getCollectionItemRow("Products Model").icon("model");
|
||||
getCollectionItemRow("Q1").icon("table2");
|
||||
|
||||
cy.url().should("not.include", "/question/" + id);
|
||||
@@ -167,9 +167,7 @@ describe("scenarios > models", () => {
|
||||
});
|
||||
|
||||
cy.findByTestId("qb-header").findAllByText("Our analytics").first().click();
|
||||
getCollectionItemCard("Product Model").within(() => {
|
||||
cy.icon("model");
|
||||
});
|
||||
getCollectionItemRow("Product Model").icon("model");
|
||||
getCollectionItemRow("Q1").icon("table2");
|
||||
|
||||
cy.location("pathname").should("eq", "/collection/root");
|
||||
@@ -603,30 +601,6 @@ describe("scenarios > models", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should automatically pin newly created models", () => {
|
||||
H.visitQuestion(ORDERS_QUESTION_ID);
|
||||
turnIntoModel();
|
||||
H.visitCollection("root");
|
||||
cy.findByTestId("pinned-items").within(() => {
|
||||
cy.findByText("Orders");
|
||||
cy.findByText("A model");
|
||||
});
|
||||
});
|
||||
|
||||
it("should undo pinning a question if turning into a model was undone", () => {
|
||||
H.visitQuestion(ORDERS_QUESTION_ID);
|
||||
|
||||
turnIntoModel();
|
||||
H.undo();
|
||||
cy.wait("@cardUpdate");
|
||||
|
||||
H.visitCollection("root");
|
||||
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Useful data").should("not.exist");
|
||||
// eslint-disable-next-line metabase/no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("A model").should("not.exist");
|
||||
});
|
||||
|
||||
describe("listing", () => {
|
||||
const modelDetails = {
|
||||
name: "Orders Model 2",
|
||||
@@ -665,10 +639,6 @@ function getCollectionItemRow(itemName) {
|
||||
return cy.findByText(itemName).closest("tr");
|
||||
}
|
||||
|
||||
function getCollectionItemCard(itemName) {
|
||||
return cy.findByText(itemName).closest("a");
|
||||
}
|
||||
|
||||
function getResults() {
|
||||
return cy.findAllByTestId("result-item");
|
||||
}
|
||||
|
||||
@@ -296,7 +296,8 @@ function archive(name) {
|
||||
}
|
||||
|
||||
function bookmarkPinnedItem(name) {
|
||||
cy.findByText(name)
|
||||
H.getPinnedSection()
|
||||
.findByText(name)
|
||||
.closest("a")
|
||||
.find(".Icon-ellipsis")
|
||||
.click({ force: true });
|
||||
|
||||
@@ -1035,11 +1035,10 @@ function assertNonAdminCannotCreatePublicLink(resource) {
|
||||
}
|
||||
|
||||
if (resource === "dashboard") {
|
||||
// No public link: dashboards keep the app link copy and the PDF export.
|
||||
// No public link: dashboards keep the app link copy
|
||||
H.openSharingMenu();
|
||||
H.sharingMenu().within(() => {
|
||||
cy.findByText("Copy link").should("be.visible");
|
||||
cy.findByText("Export as PDF").should("be.visible");
|
||||
cy.findByText("Embed").should("not.exist");
|
||||
cy.findByText(/public link/i).should("not.exist");
|
||||
});
|
||||
|
||||
@@ -782,6 +782,49 @@ describe("admin > custom visualizations", () => {
|
||||
H.tableInteractive().findByText("37.65").should("be.visible");
|
||||
});
|
||||
|
||||
it("switches away from a custom viz that cannot render the drilled data, and restores it when navigating back and forth (metabase#GDGT-2218)", () => {
|
||||
H.createQuestion(
|
||||
{
|
||||
name: "Custom Viz Drill Question",
|
||||
query: {
|
||||
"source-table": SAMPLE_DB_TABLES.STATIC_ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
},
|
||||
display: H.CUSTOM_VIZ_DISPLAY,
|
||||
},
|
||||
{ visitQuestion: true },
|
||||
);
|
||||
|
||||
H.main()
|
||||
.findByText("Custom viz rendered successfully")
|
||||
.should("be.visible");
|
||||
|
||||
cy.findByTestId("demo-viz-click-target").click();
|
||||
cy.findByTestId("click-actions-view")
|
||||
.findByText(/^Break out by/)
|
||||
.click();
|
||||
H.popover().findByText("Time").click();
|
||||
H.popover().findByText("Created At").click();
|
||||
|
||||
H.echartsContainer().should("be.visible");
|
||||
H.main()
|
||||
.findByText("Custom viz rendered successfully")
|
||||
.should("not.exist");
|
||||
|
||||
cy.go("back");
|
||||
|
||||
H.main()
|
||||
.findByText("Custom viz rendered successfully")
|
||||
.should("be.visible");
|
||||
|
||||
cy.go("forward");
|
||||
|
||||
H.echartsContainer().should("be.visible");
|
||||
H.main()
|
||||
.findByText("Custom viz rendered successfully")
|
||||
.should("not.exist");
|
||||
});
|
||||
|
||||
it("calls onHover and renders a tooltip", () => {
|
||||
H.visitQuestion("@questionId");
|
||||
switchToDemoViz();
|
||||
|
||||
@@ -15,16 +15,36 @@ export type CustomVisualizationOpts<
|
||||
VisualizationComponent: ComponentType<CustomVisualizationProps<TSettings>>;
|
||||
};
|
||||
|
||||
type PluginErrorBoundaryProps = {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
updateId: number;
|
||||
};
|
||||
|
||||
class PluginErrorBoundary extends Component<
|
||||
{ children: ReactNode; label: string },
|
||||
{ error: Error | null }
|
||||
PluginErrorBoundaryProps,
|
||||
{ error: Error | null; updateId: number }
|
||||
> {
|
||||
state: { error: Error | null } = { error: null };
|
||||
state: { error: Error | null; updateId: number } = {
|
||||
error: null,
|
||||
updateId: this.props.updateId,
|
||||
};
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
// Reset on the next update so one bad render doesn't blank the plugin forever.
|
||||
static getDerivedStateFromProps(
|
||||
props: PluginErrorBoundaryProps,
|
||||
state: { error: Error | null; updateId: number },
|
||||
) {
|
||||
if (props.updateId === state.updateId) {
|
||||
return null;
|
||||
}
|
||||
return { error: null, updateId: props.updateId };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
const { message, stack } = error;
|
||||
console.error(
|
||||
@@ -51,11 +71,13 @@ export function defineConfig<TSettings extends BaseVisualizationSettings>(
|
||||
initialProps: P,
|
||||
): CustomVisualizationMountHandle<P> {
|
||||
const root = createRoot(container);
|
||||
let updateId = 0;
|
||||
|
||||
const render = (props: P) => {
|
||||
root.render(
|
||||
<PluginErrorBoundary
|
||||
label={Component.displayName ?? Component.name ?? "plugin"}
|
||||
updateId={updateId++}
|
||||
>
|
||||
<Component {...props} />
|
||||
</PluginErrorBoundary>,
|
||||
|
||||
@@ -30,7 +30,6 @@ export async function createModelFromTable(options: Options) {
|
||||
display: "table",
|
||||
result_metadata: null,
|
||||
collection_id: collectionId,
|
||||
collection_position: 1,
|
||||
visualization_settings: {},
|
||||
dataset_query: datasetQuery,
|
||||
description: `A model created via the embedding sdk's CLI`,
|
||||
|
||||
@@ -33,7 +33,7 @@ export class DevDiagnosticsCollector {
|
||||
console.error = (...args: unknown[]) => {
|
||||
this.record({
|
||||
kind: "error",
|
||||
message: args.map((arg) => this.formatArg(arg)).join(" "),
|
||||
message: this.formatArgs(args),
|
||||
});
|
||||
|
||||
originalError(...args);
|
||||
@@ -134,6 +134,29 @@ export class DevDiagnosticsCollector {
|
||||
this.uncapturedConsoleError?.(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Join `console.error` arguments, substituting `%s` — the only specifier React warnings use.
|
||||
**/
|
||||
private formatArgs(args: unknown[]): string {
|
||||
const [format, ...rest] = args;
|
||||
|
||||
if (typeof format !== "string" || !format.includes("%s")) {
|
||||
return args.map((arg) => this.formatArg(arg)).join(" ");
|
||||
}
|
||||
|
||||
let consumed = 0;
|
||||
// Leave a `%s` alone once the arguments run out, as the console does.
|
||||
const substituted = format.replace(/%s/g, (match) =>
|
||||
consumed < rest.length ? this.formatArg(rest[consumed++]) : match,
|
||||
);
|
||||
|
||||
// Arguments past the last `%s` are appended, as the console does.
|
||||
return [
|
||||
substituted,
|
||||
...rest.slice(consumed).map((arg) => this.formatArg(arg)),
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
private formatArg(arg: unknown): string {
|
||||
if (typeof arg === "string") {
|
||||
return truncateDiagnosticText(arg);
|
||||
|
||||
@@ -53,6 +53,50 @@ describe("dev diagnostics collector", () => {
|
||||
expect(forwarded).toContainEqual(["passed through"]);
|
||||
});
|
||||
|
||||
describe("%s substitution", () => {
|
||||
const lastMessage = () =>
|
||||
formatDevDiagnostic(getLastEntry(devDiagnostics.getEntries()));
|
||||
|
||||
it("substitutes them the way the browser console renders them", () => {
|
||||
// The verbatim shape of a React warning: a format string plus its substitutions.
|
||||
console.error(
|
||||
'Warning: Each child in a list should have a unique "key" prop.%s%s See https://reactjs.org/link/warning-keys for more information.%s',
|
||||
"",
|
||||
"\n\nCheck the render method of `App`.",
|
||||
"\n in div",
|
||||
);
|
||||
|
||||
expect(lastMessage()).toBe(
|
||||
'Warning: Each child in a list should have a unique "key" prop.' +
|
||||
"\n\nCheck the render method of `App`." +
|
||||
" See https://reactjs.org/link/warning-keys for more information." +
|
||||
"\n in div",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves other specifiers to the console's raw text", () => {
|
||||
// Only `%s` is substituted — React never emits these, and getting them verbatim in
|
||||
// the toolbar is a cosmetic loss rather than a misleading one.
|
||||
console.error("%d items", 3);
|
||||
|
||||
expect(lastMessage()).toBe("%d items 3");
|
||||
});
|
||||
|
||||
it("leaves a specifier alone when no argument is left, and appends the extras", () => {
|
||||
console.error("only %s and %s", "one");
|
||||
expect(lastMessage()).toBe("only one and %s");
|
||||
|
||||
console.error("%s then", "first", "extra");
|
||||
expect(lastMessage()).toBe("first then extra");
|
||||
});
|
||||
|
||||
it("still space-joins when the first argument carries no specifier", () => {
|
||||
console.error("boom", { code: 1 });
|
||||
|
||||
expect(lastMessage()).toBe('boom {"code":1}');
|
||||
});
|
||||
});
|
||||
|
||||
it("survives arguments JSON cannot represent", () => {
|
||||
const noop = () => undefined;
|
||||
|
||||
|
||||
@@ -6,9 +6,8 @@ import { EmptyState } from "metabase/common/components/EmptyState";
|
||||
import AdminS from "metabase/css/admin.module.css";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { Box, Checkbox, Icon } from "metabase/ui";
|
||||
import { displayNameForColumn } from "metabase/utils/formatting";
|
||||
import { displayNameForColumn, formatValue } from "metabase/value-formatting";
|
||||
import { registerVisualization } from "metabase/visualizations/index";
|
||||
import { formatValue } from "metabase/visualizations/lib/formatting";
|
||||
import { isColumnRightAligned } from "metabase/visualizations/lib/table";
|
||||
import type {
|
||||
AuditTableSorting,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import { act, screen, within } from "__support__/ui";
|
||||
import type { SetupOpts } from "metabase/admin/performance/components/test-utils";
|
||||
@@ -16,7 +17,10 @@ import {
|
||||
type DurationStrategy,
|
||||
type ScheduleStrategy,
|
||||
} from "metabase-types/api";
|
||||
import { createMockTokenFeatures } from "metabase-types/api/mocks";
|
||||
import {
|
||||
createMockCacheConfig,
|
||||
createMockTokenFeatures,
|
||||
} from "metabase-types/api/mocks";
|
||||
|
||||
function setup(opts: SetupOpts = {}) {
|
||||
baseSetup({
|
||||
@@ -98,7 +102,7 @@ describe("StrategyEditorForDatabases", () => {
|
||||
|
||||
expect((await screen.findAllByRole("spinbutton")).length).toBe(1);
|
||||
|
||||
await changeInput(/Cache duration/, 24, 48);
|
||||
await changeInput(/Cache duration/, 0, 48);
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByTestId("strategy-form-submit-button"),
|
||||
@@ -169,7 +173,11 @@ describe("StrategyEditorForDatabases", () => {
|
||||
|
||||
expect((await screen.findAllByRole("spinbutton")).length).toBe(1);
|
||||
|
||||
await changeInput(/Cache duration/, 24, 48);
|
||||
expect(await screen.findByTestId("duration-unit-select")).toHaveValue(
|
||||
"hours",
|
||||
);
|
||||
|
||||
await changeInput(/Cache duration/, 0, 48);
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByTestId("strategy-form-submit-button"),
|
||||
@@ -250,15 +258,118 @@ describe("StrategyEditorForDatabases", () => {
|
||||
expect(result).toBe("Scheduled: hourly");
|
||||
});
|
||||
|
||||
it("can abbreviate a 'Duration' strategy", () => {
|
||||
it.each([
|
||||
[CacheDurationUnit.Hours, "Duration: 5h"],
|
||||
[CacheDurationUnit.Minutes, "Duration: 5m"],
|
||||
[CacheDurationUnit.Seconds, "Duration: 5s"],
|
||||
[CacheDurationUnit.Days, "Duration: 5d"],
|
||||
])("can abbreviate a 'Duration' strategy with unit %s", (unit, expected) => {
|
||||
const strategy: DurationStrategy = {
|
||||
type: "duration",
|
||||
duration: 5,
|
||||
unit: CacheDurationUnit.Hours,
|
||||
unit,
|
||||
refresh_automatically: false,
|
||||
};
|
||||
const result = getShortStrategyLabel(strategy);
|
||||
expect(result).toBe("Duration: 5h");
|
||||
expect(getShortStrategyLabel(strategy)).toBe(expected);
|
||||
});
|
||||
|
||||
it("does not allow saving an empty cache duration", async () => {
|
||||
await userEvent.click(
|
||||
await screen.findByLabelText(
|
||||
"Edit default policy (currently: Duration: 1h)",
|
||||
),
|
||||
);
|
||||
|
||||
const input = await screen.findByRole("spinbutton", {
|
||||
name: /Cache duration/,
|
||||
});
|
||||
await userEvent.clear(input);
|
||||
await userEvent.click(
|
||||
await screen.findByTestId("strategy-form-submit-button"),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText("Enter a positive number."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
fetchMock.callHistory.calls("path:/api/cache", { method: "PUT" }),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("pluralizes the unit options with the entered duration", async () => {
|
||||
await userEvent.click(
|
||||
await screen.findByLabelText(
|
||||
"Edit default policy (currently: Duration: 1h)",
|
||||
),
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId("duration-unit-select")).toHaveValue(
|
||||
"hour",
|
||||
);
|
||||
|
||||
await changeInput(/Cache duration/, 0, 2);
|
||||
|
||||
expect(await screen.findByTestId("duration-unit-select")).toHaveValue(
|
||||
"hours",
|
||||
);
|
||||
});
|
||||
|
||||
it("lets user change the duration unit", async () => {
|
||||
await userEvent.click(
|
||||
await screen.findByLabelText(
|
||||
"Edit default policy (currently: Duration: 1h)",
|
||||
),
|
||||
);
|
||||
|
||||
await userEvent.click(await screen.findByTestId("duration-unit-select"));
|
||||
await userEvent.click(
|
||||
await screen.findByRole("option", { name: "minute" }),
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByTestId("strategy-form-submit-button"),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByLabelText(
|
||||
"Edit default policy (currently: Duration: 1m)",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("StrategyEditorForDatabases (sub-hour duration policy)", () => {
|
||||
beforeEach(() => {
|
||||
setup({
|
||||
cacheConfigs: [
|
||||
createMockCacheConfig({
|
||||
model: "root",
|
||||
model_id: 0,
|
||||
strategy: {
|
||||
type: "duration",
|
||||
duration: 30,
|
||||
unit: CacheDurationUnit.Minutes,
|
||||
refresh_automatically: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a policy stored with a sub-hour unit faithfully", async () => {
|
||||
await userEvent.click(
|
||||
await screen.findByLabelText(
|
||||
"Edit default policy (currently: Duration: 30m)",
|
||||
),
|
||||
);
|
||||
|
||||
expect(await getCacheStrategySelect()).toHaveValue("Duration");
|
||||
expect(
|
||||
await screen.findByRole("spinbutton", { name: /Cache duration/ }),
|
||||
).toHaveValue(30);
|
||||
expect(await screen.findByTestId("duration-unit-select")).toHaveValue(
|
||||
"minutes",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import type { ColumnItem } from "metabase/common/components/Table/types";
|
||||
import { usePagination } from "metabase/common/hooks/use-pagination";
|
||||
import { Center, Flex, Repeat, Skeleton, Stack } from "metabase/ui";
|
||||
import type { CacheSortColumn, CacheableModel } from "metabase-types/api";
|
||||
import { CacheDurationUnit } from "metabase-types/api";
|
||||
import type { SortDirection } from "metabase-types/api/sorting";
|
||||
|
||||
import type { CacheableItem, UpdateTarget } from "../types";
|
||||
@@ -100,16 +99,7 @@ export const StrategyEditorForQuestionsAndDashboards = () => {
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const savedStrategy = useMemo(() => {
|
||||
const strategy = targetConfig?.strategy;
|
||||
if (!strategy) {
|
||||
return undefined;
|
||||
}
|
||||
if (strategy.type === "duration") {
|
||||
return { ...strategy, unit: CacheDurationUnit.Hours };
|
||||
}
|
||||
return { ...strategy };
|
||||
}, [targetConfig?.strategy]);
|
||||
const savedStrategy = targetConfig?.strategy;
|
||||
|
||||
const targetName = useMemo(() => {
|
||||
if (targetId === null || targetModel === null) {
|
||||
|
||||
@@ -3,24 +3,29 @@ import _ from "underscore";
|
||||
|
||||
import { getShortStrategyLabel } from "metabase/admin/performance/utils";
|
||||
import { getCollectionPathAsString } from "metabase/common/collections/utils";
|
||||
import { CacheDurationUnit } from "metabase-types/api";
|
||||
|
||||
import type { CacheableItem } from "../types";
|
||||
|
||||
const DURATION_UNIT_SECONDS: Record<CacheDurationUnit, number> = {
|
||||
[CacheDurationUnit.Seconds]: 1,
|
||||
[CacheDurationUnit.Minutes]: 60,
|
||||
[CacheDurationUnit.Hours]: 3600,
|
||||
[CacheDurationUnit.Days]: 86400,
|
||||
};
|
||||
|
||||
export const formatValueForSorting = (
|
||||
row: CacheableItem,
|
||||
columnName: string,
|
||||
) => {
|
||||
if (columnName === "policy") {
|
||||
const label = getShortStrategyLabel(row.strategy, row.model);
|
||||
if (row.strategy.type === "duration") {
|
||||
// Sort durations in ascending order of length
|
||||
return label?.replace(/(\d+)h/, (_, num) => {
|
||||
const paddedNumber = num.padStart(5, "0");
|
||||
return `${t`Duration`} ${paddedNumber}`;
|
||||
});
|
||||
} else {
|
||||
return label;
|
||||
const seconds =
|
||||
row.strategy.duration * DURATION_UNIT_SECONDS[row.strategy.unit];
|
||||
return `${t`Duration`} ${String(seconds).padStart(12, "0")}`;
|
||||
}
|
||||
return getShortStrategyLabel(row.strategy, row.model);
|
||||
}
|
||||
if (columnName === "collection") {
|
||||
return row.collection ? getCollectionPathAsString(row.collection) : "";
|
||||
|
||||
@@ -170,6 +170,42 @@ describe("StrategyEditorForQuestionsAndDashboards utilities", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts durations with mixed units by real length", () => {
|
||||
const durationRow = (
|
||||
id: number,
|
||||
duration: number,
|
||||
unit: CacheDurationUnit,
|
||||
): CacheableItem => ({
|
||||
model: "question",
|
||||
id,
|
||||
strategy: {
|
||||
type: "duration",
|
||||
duration,
|
||||
unit,
|
||||
refresh_automatically: false,
|
||||
},
|
||||
});
|
||||
const rows = [
|
||||
durationRow(0, 1, CacheDurationUnit.Days),
|
||||
durationRow(1, 100, CacheDurationUnit.Minutes),
|
||||
durationRow(2, 90, CacheDurationUnit.Seconds),
|
||||
durationRow(3, 10, CacheDurationUnit.Hours),
|
||||
durationRow(4, 30, CacheDurationUnit.Seconds),
|
||||
];
|
||||
const sorted = rows.sort((rowA, rowB) => {
|
||||
const a = formatValueForSorting(rowA, "policy");
|
||||
const b = formatValueForSorting(rowB, "policy");
|
||||
return String(a).localeCompare(String(b));
|
||||
});
|
||||
expect(sorted.map((row) => getShortStrategyLabel(row.strategy))).toEqual([
|
||||
"Duration: 30s",
|
||||
"Duration: 90s",
|
||||
"Duration: 100m",
|
||||
"Duration: 10h",
|
||||
"Duration: 1d",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts by collection correctly", () => {
|
||||
const sorted = unsortedRows.sort((rowA, rowB) => {
|
||||
// Unjustified type cast. FIXME
|
||||
|
||||
@@ -28,11 +28,13 @@ const getDurationStrategyValidationSchema = () =>
|
||||
Yup.object({
|
||||
type: Yup.string().equals(["duration"]),
|
||||
duration: getPositiveIntegerSchema().default(24),
|
||||
unit: Yup.string().test(
|
||||
"is-duration-unit",
|
||||
"${path} is not a valid duration",
|
||||
(value) => !!value && durationUnits.has(value),
|
||||
),
|
||||
unit: Yup.string()
|
||||
.test(
|
||||
"is-duration-unit",
|
||||
"${path} is not a valid duration",
|
||||
(value) => !!value && durationUnits.has(value),
|
||||
)
|
||||
.default(CacheDurationUnit.Hours),
|
||||
refresh_automatically: Yup.boolean().nullable().default(false),
|
||||
});
|
||||
|
||||
@@ -47,7 +49,7 @@ export const enterpriseOnlyCachingStrategies: Record<string, StrategyData> = {
|
||||
},
|
||||
duration: {
|
||||
label: () => t`Duration`,
|
||||
description: () => t`Keep the cache for a number of hours`,
|
||||
description: () => t`Keep the cache for a set duration`,
|
||||
validationSchema: getDurationStrategyValidationSchema,
|
||||
shortLabel: () => t`Duration`,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import { act, screen, waitFor, within } from "__support__/ui";
|
||||
import { setup } from "metabase/query_builder/containers/test-utils";
|
||||
import { getCard } from "metabase/query_builder/selectors";
|
||||
import { checkNotNull } from "metabase/utils/types";
|
||||
import { registerVisualization } from "metabase/visualizations";
|
||||
import { registerVisualizations } from "metabase/visualizations/register";
|
||||
import type { VisualizationProps } from "metabase/visualizations/types/visualization";
|
||||
import { isDate } from "metabase-lib/v1/types/utils/isa";
|
||||
import type { CustomVizDisplayType } from "metabase-types/api";
|
||||
import {
|
||||
createMockCard,
|
||||
createMockCategoryColumn,
|
||||
createMockCustomVizPluginRuntime,
|
||||
createMockDataset,
|
||||
createMockDatasetData,
|
||||
createMockNumericColumn,
|
||||
} from "metabase-types/api/mocks";
|
||||
import {
|
||||
ORDERS,
|
||||
ORDERS_ID,
|
||||
SAMPLE_DB_ID,
|
||||
createOrdersCreatedAtDatasetColumn,
|
||||
createOrdersIdDatasetColumn,
|
||||
createOrdersTotalDatasetColumn,
|
||||
} from "metabase-types/api/mocks/presets";
|
||||
|
||||
import { applyDefaultVisualizationProps } from "./custom-viz-common";
|
||||
|
||||
const DISPLAY: CustomVizDisplayType = "custom:drill-demo-viz";
|
||||
|
||||
const CREATED_AT_COLUMN = createOrdersCreatedAtDatasetColumn({
|
||||
source: "breakout",
|
||||
unit: "month",
|
||||
field_ref: ["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }],
|
||||
});
|
||||
|
||||
const COUNT_COLUMN = createMockNumericColumn({
|
||||
name: "count",
|
||||
display_name: "Count",
|
||||
semantic_type: "type/Quantity",
|
||||
source: "aggregation",
|
||||
field_ref: ["aggregation", 0],
|
||||
});
|
||||
|
||||
const DATASET = createMockDataset({
|
||||
data: createMockDatasetData({
|
||||
cols: [CREATED_AT_COLUMN, COUNT_COLUMN],
|
||||
rows: [
|
||||
["2026-01-01T00:00:00Z", 10],
|
||||
["2026-02-01T00:00:00Z", 20],
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const UNDERLYING_RECORDS_DATASET = createMockDataset({
|
||||
data: createMockDatasetData({
|
||||
cols: [createOrdersIdDatasetColumn(), createOrdersTotalDatasetColumn()],
|
||||
rows: [[1, 100]],
|
||||
}),
|
||||
});
|
||||
|
||||
const CATEGORY_DATASET = createMockDataset({
|
||||
data: createMockDatasetData({
|
||||
cols: [
|
||||
createMockCategoryColumn({
|
||||
name: "CATEGORY",
|
||||
display_name: "Category",
|
||||
source: "breakout",
|
||||
}),
|
||||
COUNT_COLUMN,
|
||||
],
|
||||
rows: [
|
||||
["Doohickey", 10],
|
||||
["Gadget", 20],
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const CARD = createMockCard({
|
||||
id: 1,
|
||||
name: "Orders by month",
|
||||
description: "Count of orders bucketed by month",
|
||||
type: "question",
|
||||
display: DISPLAY,
|
||||
visualization_settings: {
|
||||
"card.title": "Orders by month",
|
||||
"custom.setting": "kept",
|
||||
},
|
||||
dataset_query: {
|
||||
database: SAMPLE_DB_ID,
|
||||
type: "query",
|
||||
query: {
|
||||
"source-table": ORDERS_ID,
|
||||
aggregation: [["count"]],
|
||||
breakout: [["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }]],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function DemoVisualization({ onVisualizationClick }: VisualizationProps) {
|
||||
const [createdAt, count] = DATASET.data.rows[0];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span>Custom viz rendered</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) =>
|
||||
onVisualizationClick?.({
|
||||
value: count,
|
||||
column: COUNT_COLUMN,
|
||||
event: event.nativeEvent,
|
||||
element: event.currentTarget,
|
||||
data: [
|
||||
{ col: CREATED_AT_COLUMN, value: createdAt },
|
||||
{ col: COUNT_COLUMN, value: count },
|
||||
],
|
||||
dimensions: [{ column: CREATED_AT_COLUMN, value: createdAt }],
|
||||
})
|
||||
}
|
||||
>
|
||||
Click me
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerVisualizations();
|
||||
|
||||
registerVisualization(
|
||||
applyDefaultVisualizationProps(
|
||||
DemoVisualization,
|
||||
{
|
||||
id: DISPLAY,
|
||||
getName: () => "Drill demo viz",
|
||||
checkRenderable: ([{ data }]) => {
|
||||
if (!data?.cols.some(isDate)) {
|
||||
throw new Error("Needs a date column");
|
||||
}
|
||||
},
|
||||
mount: () => ({ update: () => undefined, unmount: () => undefined }),
|
||||
VisualizationComponent: () => null,
|
||||
},
|
||||
{
|
||||
identifier: DISPLAY,
|
||||
plugin: createMockCustomVizPluginRuntime(),
|
||||
getUiName: () => "Drill demo viz",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
async function drillFromCustomViz(actionName: RegExp) {
|
||||
expect(await screen.findByText("Custom viz rendered")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Click me" }));
|
||||
|
||||
const clickActions = await screen.findByTestId("click-actions-view");
|
||||
await userEvent.click(within(clickActions).getByText(actionName));
|
||||
}
|
||||
|
||||
async function expectFallbackChart() {
|
||||
expect(await screen.findByTestId("chart-container")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Custom viz rendered")).not.toBeInTheDocument();
|
||||
}
|
||||
|
||||
describe("query builder > custom visualization drill-through", () => {
|
||||
it("should keep the custom visualization selected after drilling (metabase#GDGT-2286)", async () => {
|
||||
const { store } = await setup({ card: CARD, dataset: DATASET });
|
||||
|
||||
await drillFromCustomViz(/See this month by week/);
|
||||
|
||||
expect(await screen.findByText("Custom viz rendered")).toBeInTheDocument();
|
||||
|
||||
const card = checkNotNull(getCard(store.getState()));
|
||||
expect(card.display).toBe(DISPLAY);
|
||||
});
|
||||
|
||||
it("should switch away from a custom visualization that cannot render the drilled data, and back again when navigating back and forth (metabase#GDGT-2218)", async () => {
|
||||
const { router } = await setup({ card: CARD, dataset: DATASET });
|
||||
|
||||
// the drilled query comes back without a date column, so the custom
|
||||
// visualization can no longer render it
|
||||
fetchMock.modifyRoute("dataset-post", { response: CATEGORY_DATASET });
|
||||
|
||||
await drillFromCustomViz(/See this month by week/);
|
||||
|
||||
await expectFallbackChart();
|
||||
|
||||
act(() => router.back());
|
||||
|
||||
expect(await screen.findByText("Custom viz rendered")).toBeInTheDocument();
|
||||
|
||||
act(() => router.forward());
|
||||
|
||||
await expectFallbackChart();
|
||||
});
|
||||
|
||||
it("should still switch to a table for the underlying records drill", async () => {
|
||||
const { store } = await setup({ card: CARD, dataset: DATASET });
|
||||
|
||||
fetchMock.modifyRoute("dataset-post", {
|
||||
response: UNDERLYING_RECORDS_DATASET,
|
||||
});
|
||||
|
||||
await drillFromCustomViz(/See these Orders/);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(checkNotNull(getCard(store.getState())).display).toBe("table");
|
||||
});
|
||||
expect(screen.queryByText("Custom viz rendered")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
OmniPickerCollectionItem,
|
||||
OmniPickerItem,
|
||||
} from "metabase/common/components/Pickers/EntityPicker/types";
|
||||
import { allCollectionModels } from "metabase/common/components/Pickers/EntityPicker/utils";
|
||||
import { allCollectionModels } from "metabase/common/components/Pickers/utils";
|
||||
import type {
|
||||
GetEntityPickerSyntheticLibraryItemFunction,
|
||||
LibrarySubCollectionType,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatNumber } from "metabase/utils/formatting";
|
||||
import { formatDateTimeWithUnit } from "metabase/visualizations/lib/formatting";
|
||||
import { formatDateTimeWithUnit } from "metabase/value-formatting";
|
||||
import type { BillingInfoLineItem } from "metabase-types/api";
|
||||
import {
|
||||
supportedDisplayTypes,
|
||||
|
||||
@@ -1,32 +1,88 @@
|
||||
import { Route, redirect } from "metabase/router";
|
||||
import { Route, redirect, registerPagePrefetch } from "metabase/router";
|
||||
import * as Urls from "metabase/urls";
|
||||
|
||||
import { CliAnalyticsPage } from "./cli-analytics/components/CliAnalyticsPage";
|
||||
import {
|
||||
McpAnalyticsAvailabilityLayout,
|
||||
MetabotAnalyticsAvailabilityLayout,
|
||||
} from "./components/AvailabilityLayouts";
|
||||
import { McpAnalyticsPage } from "./mcp-analytics/components/McpAnalyticsPage";
|
||||
import { ConversationDetailPage } from "./metabot-analytics/components/ConversationDetailPage";
|
||||
import { ConversationStatsPage } from "./metabot-analytics/components/ConversationStatsPage";
|
||||
import { ConversationsPage } from "./metabot-analytics/components/ConversationsPage";
|
||||
import { MetabotAnalyticsUpsellPage } from "./metabot-analytics/components/MetabotAnalyticsUpsellPage/MetabotAnalyticsUpsellPage";
|
||||
|
||||
/**
|
||||
* The AI auditing pages, each in its own chunk.
|
||||
*
|
||||
* The plugin registry assigns these routes on every page load, so whatever they
|
||||
* name is in the initial bundle. The pages carry the charting and data grid
|
||||
* stack, which no other page needs on first paint.
|
||||
*
|
||||
* The route shape stays eager, so matching is unchanged. The availability gates
|
||||
* above them still decide what renders, but they now decide a tick later: the
|
||||
* router resolves a matched route's `lazy` before it commits, which means a page
|
||||
* a gate is about to block is fetched anyway. That is a few tens of kilobytes on
|
||||
* an admin page whose feature is switched off, in exchange for one splitting
|
||||
* mechanism across the app.
|
||||
*/
|
||||
const conversationStatsPage = () =>
|
||||
import("./metabot-analytics/components/ConversationStatsPage").then(
|
||||
({ ConversationStatsPage }) => ({ Component: ConversationStatsPage }),
|
||||
);
|
||||
|
||||
const conversationsPage = () =>
|
||||
import("./metabot-analytics/components/ConversationsPage").then(
|
||||
({ ConversationsPage }) => ({ Component: ConversationsPage }),
|
||||
);
|
||||
|
||||
const conversationDetailPage = () =>
|
||||
import("./metabot-analytics/components/ConversationDetailPage").then(
|
||||
({ ConversationDetailPage }) => ({ Component: ConversationDetailPage }),
|
||||
);
|
||||
|
||||
const metabotAnalyticsUpsellPage = () =>
|
||||
import("./metabot-analytics/components/MetabotAnalyticsUpsellPage/MetabotAnalyticsUpsellPage").then(
|
||||
({ MetabotAnalyticsUpsellPage }) => ({
|
||||
Component: MetabotAnalyticsUpsellPage,
|
||||
}),
|
||||
);
|
||||
|
||||
const mcpAnalyticsPage = () =>
|
||||
import("./mcp-analytics/components/McpAnalyticsPage").then(
|
||||
({ McpAnalyticsPage }) => ({ Component: McpAnalyticsPage }),
|
||||
);
|
||||
|
||||
const cliAnalyticsPage = () =>
|
||||
import("./cli-analytics/components/CliAnalyticsPage").then(
|
||||
({ CliAnalyticsPage }) => ({ Component: CliAnalyticsPage }),
|
||||
);
|
||||
|
||||
/**
|
||||
* Hovering a Monitor sidebar link starts the fetch, so the chunk is usually in
|
||||
* hand by the time the click lands.
|
||||
*
|
||||
* `/usage` renders the stats page or the upsell page depending on the license,
|
||||
* so hovering it asks for both. The conversation detail page takes the trailing
|
||||
* slash, which keeps the link to the list from dragging it in as well.
|
||||
*/
|
||||
registerPagePrefetch(Urls.monitorAiAuditingUsage(), conversationStatsPage);
|
||||
registerPagePrefetch(Urls.monitorAiAuditingUsage(), metabotAnalyticsUpsellPage);
|
||||
registerPagePrefetch(Urls.monitorAiAuditingConversations(), conversationsPage);
|
||||
registerPagePrefetch(
|
||||
`${Urls.monitorAiAuditingConversations()}/`,
|
||||
conversationDetailPage,
|
||||
);
|
||||
registerPagePrefetch(Urls.monitorAiAuditingMcp(), mcpAnalyticsPage);
|
||||
registerPagePrefetch(Urls.monitorAiAuditingCli(), cliAnalyticsPage);
|
||||
|
||||
export function getAiAuditingRoutes() {
|
||||
return (
|
||||
<>
|
||||
<Route index element={redirect("usage")} />
|
||||
<Route element={<MetabotAnalyticsAvailabilityLayout />}>
|
||||
<Route path="usage" element={<ConversationStatsPage />} />
|
||||
<Route path="conversations" element={<ConversationsPage />} />
|
||||
<Route
|
||||
path="conversations/:convoId"
|
||||
element={<ConversationDetailPage />}
|
||||
/>
|
||||
<Route path="usage" lazy={conversationStatsPage} />
|
||||
<Route path="conversations" lazy={conversationsPage} />
|
||||
<Route path="conversations/:convoId" lazy={conversationDetailPage} />
|
||||
</Route>
|
||||
<Route element={<McpAnalyticsAvailabilityLayout />}>
|
||||
<Route path="mcp" element={<McpAnalyticsPage />} />
|
||||
<Route path="mcp" lazy={mcpAnalyticsPage} />
|
||||
</Route>
|
||||
<Route path="cli" element={<CliAnalyticsPage />} />
|
||||
<Route path="cli" lazy={cliAnalyticsPage} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -35,11 +91,11 @@ export function getAiAuditingUpsellRoutes() {
|
||||
return (
|
||||
<>
|
||||
<Route index element={redirect("usage")} />
|
||||
<Route path="usage" element={<MetabotAnalyticsUpsellPage />} />
|
||||
<Route path="usage" lazy={metabotAnalyticsUpsellPage} />
|
||||
<Route element={<McpAnalyticsAvailabilityLayout />}>
|
||||
<Route path="mcp" element={<McpAnalyticsPage />} />
|
||||
<Route path="mcp" lazy={mcpAnalyticsPage} />
|
||||
</Route>
|
||||
<Route path="cli" element={<CliAnalyticsPage />} />
|
||||
<Route path="cli" lazy={cliAnalyticsPage} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,16 +83,18 @@ describe("AI Auditing routes", () => {
|
||||
[Urls.monitorAiAuditingUsage(), "Usage stats page"],
|
||||
["/monitor/ai-auditing/conversations", "Conversations page"],
|
||||
["/monitor/ai-auditing/conversations/42", "Conversation detail page"],
|
||||
])("blocks %s when AI features are disabled", (route, pageText) => {
|
||||
])("blocks %s when AI features are disabled", async (route, pageText) => {
|
||||
setup({ route, aiFeaturesEnabled: false });
|
||||
|
||||
expect(screen.getByText("AI features are disabled")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("AI features are disabled"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(pageText)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"prioritizes globally disabled AI on the MCP route when upsell is %s",
|
||||
(upsell) => {
|
||||
async (upsell) => {
|
||||
setup({
|
||||
route: "/monitor/ai-auditing/mcp",
|
||||
upsell,
|
||||
@@ -100,7 +102,9 @@ describe("AI Auditing routes", () => {
|
||||
mcpEnabled: false,
|
||||
});
|
||||
|
||||
expect(screen.getByText("AI features are disabled")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("AI features are disabled"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("MCP analytics page")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Go to AI Settings" }),
|
||||
@@ -108,26 +112,26 @@ describe("AI Auditing routes", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("renders Usage stats at the canonical route", () => {
|
||||
it("renders Usage stats at the canonical route", async () => {
|
||||
setup({ route: Urls.monitorAiAuditingUsage() });
|
||||
|
||||
expect(screen.getByText("Usage stats page")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Usage stats page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders full Metabot analytics when enabled and configured", () => {
|
||||
it("renders full Metabot analytics when enabled and configured", async () => {
|
||||
setup({ route: "/monitor/ai-auditing/conversations" });
|
||||
|
||||
expect(screen.getByText("Conversations page")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Conversations page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blocks MCP analytics when MCP is disabled", () => {
|
||||
it("blocks MCP analytics when MCP is disabled", async () => {
|
||||
setup({ route: "/monitor/ai-auditing/mcp", mcpEnabled: false });
|
||||
|
||||
expect(screen.getByText("MCP is disabled")).toBeInTheDocument();
|
||||
expect(await screen.findByText("MCP is disabled")).toBeInTheDocument();
|
||||
expect(screen.queryByText("MCP analytics page")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the license upsell ahead of AI configuration", () => {
|
||||
it("keeps the license upsell ahead of AI configuration", async () => {
|
||||
setup({
|
||||
route: Urls.monitorAiAuditingUsage(),
|
||||
upsell: true,
|
||||
@@ -135,7 +139,7 @@ describe("AI Auditing routes", () => {
|
||||
isConfigured: false,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Usage stats upsell")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Usage stats upsell")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("AI features are disabled"),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -143,7 +147,7 @@ describe("AI Auditing routes", () => {
|
||||
|
||||
it.each([false, true])(
|
||||
"renders CLI analytics unconditionally when upsell is %s",
|
||||
(upsell) => {
|
||||
async (upsell) => {
|
||||
setup({
|
||||
route: Urls.monitorAiAuditingCli(),
|
||||
upsell,
|
||||
@@ -152,18 +156,18 @@ describe("AI Auditing routes", () => {
|
||||
mcpEnabled: false,
|
||||
});
|
||||
|
||||
expect(screen.getByText("CLI analytics page")).toBeInTheDocument();
|
||||
expect(await screen.findByText("CLI analytics page")).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("applies MCP availability to the upsell route set", () => {
|
||||
it("applies MCP availability to the upsell route set", async () => {
|
||||
setup({
|
||||
route: "/monitor/ai-auditing/mcp",
|
||||
upsell: true,
|
||||
mcpEnabled: false,
|
||||
});
|
||||
|
||||
expect(screen.getByText("MCP is disabled")).toBeInTheDocument();
|
||||
expect(await screen.findByText("MCP is disabled")).toBeInTheDocument();
|
||||
expect(screen.queryByText("MCP analytics page")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
Text,
|
||||
} from "metabase/ui";
|
||||
import * as Errors from "metabase/utils/errors";
|
||||
import { slugify } from "metabase/visualizations/lib/formatting/url";
|
||||
import { slugify } from "metabase/utils/formatting";
|
||||
import { useReplaceModelWithTransformMutation } from "metabase-enterprise/api";
|
||||
import type { Card, Database } from "metabase-types/api";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { msgid, ngettext, t } from "ttag";
|
||||
|
||||
import { formatNumber } from "metabase/utils/formatting/numbers";
|
||||
import { formatNumber } from "metabase/utils/formatting";
|
||||
import { HARD_ROW_LIMIT } from "metabase-lib/v1/queries/utils/index";
|
||||
import type { Dataset } from "metabase-types/api";
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user