Compare commits

...

22 Commits

Author SHA1 Message Date
Đỗ Trọng Hải
4e3ff37137 chore(ci): resolve shellcheck-flagged errors (#42430)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-08-03 00:09:04 +07:00
Joe Li
120b4420b9 fix(versioning): narrow UUIDs in restore tests (#42654) 2026-08-02 23:54:30 +07:00
dependabot[bot]
0628b0b813 chore(deps-dev): update thrift requirement from <1.0.0,>=0.23.0 to >=0.24.0,<1.0.0 (#42667)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-02 23:45:06 +07:00
dependabot[bot]
378634cceb chore(deps-dev): update pyathena requirement from <4,>=2 to >=3.35.2,<4 (#42665)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-02 23:44:48 +07:00
dependabot[bot]
af7472fd7e chore(deps-dev): update databricks-sql-connector requirement from <4.4.0,>=4.2.6 to >=4.4.0,<4.5.0 (#42668)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-02 23:36:20 +07:00
dependabot[bot]
36e3ebecfa chore(deps): bump croniter from 6.2.2 to 6.2.4 (#42664)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-02 17:22:56 +07:00
dependabot[bot]
3f201c1c77 chore(deps): bump polyline from 2.0.2 to 2.0.4 (#42666)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-02 17:22:48 +07:00
dependabot[bot]
cc4cd3e98a chore(deps): bump cachetools from 7.1.4 to 7.1.6 (#42663)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-02 17:06:14 +07:00
dependabot[bot]
322ae841e5 chore(deps-dev): update clickhouse-connect requirement from <2.0,>=1.4.2 to >=1.6.0,<2.0 (#42669)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-02 17:05:57 +07:00
dependabot[bot]
5ccccc8c69 chore(deps): bump greenlet from 3.5.3 to 3.5.4 (#42670)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-02 17:05:34 +07:00
dependabot[bot]
3d0ee8b4c5 chore(deps-dev): bump prophet from 1.2.0 to 1.3.0 (#42672)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-02 17:05:20 +07:00
yousoph
ff9bec2b99 fix(dashboard): make chart error messages expandable again (#42491)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 15:26:31 -07:00
Beto Dealmeida
9f66cb566b feat(semantic layers): don't show samples tab in explore (#41509) 2026-08-01 16:03:20 -04:00
0xdorian-sm
29ac93862e fix(i18n-fr): translate the 85 remaining untranslated strings (#42577)
Signed-off-by: Dorian Saint-Martin <dorian.saintmartin@outlook.fr>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-08-01 10:39:59 -07:00
Evan Rusackas
c9b159b4e7 docs(db_engine_specs): link upstream Pinot timestamp fix in TODO (#42623)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-08-01 10:39:14 -07:00
Evan Rusackas
d336d2a8b6 feat(ci): auto-label PRs with merge conflicts using requires:rebase (#42504)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:38:02 -07:00
Amin Ghadersohi
22c305f758 fix(dataset): retry metadata after OAuth2 authorization (#42581) 2026-07-31 20:41:42 -04:00
Elizabeth Thompson
6929d032b8 fix(reports): time-budget tiled screenshot to fail cleanly instead of hitting Celery kill (#42118)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-31 15:37:46 -07:00
Elizabeth Thompson
f6c574edd8 fix(screenshots): validate cached screenshot image bytes on read and write (#42120)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-31 15:13:59 -07:00
Elizabeth Thompson
b452c1634d fix(a11y): add aria-label to dashboard IconButton and unlabeled call sites (#41470)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 15:05:32 -07:00
Elizabeth Thompson
1bfbce3cfd fix(alerts-reports): catch CroniterBadDateError in report frequency validation (#42650) 2026-07-31 15:04:30 -07:00
Elizabeth Thompson
85bab0b07c fix(reports): downgrade chart-container timeout log level and fix tiling veto on unknown height (#42153)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-31 15:00:38 -07:00
68 changed files with 2067 additions and 313 deletions

View File

@@ -0,0 +1,54 @@
name: Label Merge Conflicts
# Sweeps every open PR and labels the ones GitHub reports as CONFLICTING with
# `requires:rebase` (removing it once a rebase makes the PR mergeable again),
# so the label can be used to filter the PR backlog for the ones that need a
# rebase before they can be reviewed/merged.
#
# The action itself always re-checks *every* open PR via GraphQL on each run
# regardless of what triggered it (see eps1lon/actions-label-merge-conflict's
# sources/main.ts) - there's no way to scope it to "just this PR". The
# project's own README suggests triggering on `push` (to the default branch)
# plus `pull_request_target: [synchronize]`, but on a repo with Superset's PR
# volume that combination would re-sweep the entire open-PR list on every
# merge to master *and* every push to *any* open PR - many times an hour.
# A schedule bounds that to a fixed, predictable cadence instead; adjust it
# if 2 hours turns out to be too slow or too chatty in practice.
on:
schedule:
- cron: "0 */2 * * *"
workflow_dispatch:
# Avoid two full backlog sweeps racing (a manual workflow_dispatch landing
# mid-schedule-tick, say); queue rather than cancel so an in-progress
# paginated sweep always runs to completion.
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
permissions: {}
jobs:
label-merge-conflicts:
# Scheduled/dispatch workflows still run on forks that carry this file;
# skip anywhere but the canonical repo.
if: github.repository == 'apache/superset'
name: Label Merge Conflicts
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # to add/remove requires:rebase and need:merge
steps:
# ASF Infra allowlists this whole action via a wildcard
# (eps1lon/actions-label-merge-conflict@*), so any pinned SHA/version
# is already fine here - no Infra ticket needed for future bumps.
- uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
with:
dirtyLabel: "requires:rebase"
# A conflicting PR isn't actually ready to merge; strip that signal
# if it was previously set so reviewers don't act on a stale one.
removeOnDirtyLabel: "need:merge"
repoToken: ${{ secrets.GITHUB_TOKEN }}
# Intentionally no commentOnDirty/commentOnClean: the label alone is
# the signal (matches the label's existing description, and avoids
# a one-time comment storm across the whole backlog on first run).

View File

@@ -1,3 +1,5 @@
#!/usr/bin/env bash
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
@@ -15,8 +17,6 @@
# specific language governing permissions and limitations
# under the License.
#!/bin/bash
# Function to determine Python command
get_python_command() {
if command -v python3 &>/dev/null; then

View File

@@ -38,7 +38,7 @@ RESET='\033[0m'
echo -e "${GREEN}Updating package lists...${RESET}"
apt-get update -qq
echo -e "${GREEN}Installing packages: $@${RESET}"
echo -e "${GREEN}Installing packages: $*${RESET}"
apt-get install -yqq --no-install-recommends "$@"
echo -e "${GREEN}Autoremoving unnecessary packages...${RESET}"

View File

@@ -163,10 +163,10 @@ do
# Iterate through the components of the version strings
for (( j=0; j<${#THIS_TAG_NAME_ARRAY[@]}; j++ )); do
echo "Comparing ${THIS_TAG_NAME_ARRAY[$j]} to ${LATEST_RELEASE_TAG_ARRAY[$j]}"
if [[ $((THIS_TAG_NAME_ARRAY[$j])) > $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
if [[ $((THIS_TAG_NAME_ARRAY[$j])) -gt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
compare_result="greater"
break
elif [[ $((THIS_TAG_NAME_ARRAY[$j])) < $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
elif [[ $((THIS_TAG_NAME_ARRAY[$j])) -lt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
compare_result="lesser"
break
fi

View File

@@ -42,13 +42,13 @@ dependencies = [
# ``google-auth`` 2.53+ dropped it, so Superset must declare it
# explicitly to keep fresh ``pip install apache-superset`` working
# without the ``base.txt`` lock file (#40962).
"cachetools>=7.1.4, <8",
"cachetools>=7.1.6, <8",
"celery>=5.6.3, <6.0.0",
"click>=8.4.2",
"click-option-group",
"colorama",
"flask-cors>=6.0.5, <7.0",
"croniter>=6.2.2",
"croniter>=6.2.4",
"cron-descriptor",
"cryptography>=49.0.0, <50.0.0",
"deprecation>=2.1.0, <2.2.0",
@@ -62,7 +62,7 @@ dependencies = [
"flask-session>=0.4.0, <1.0",
"flask-wtf>=1.3.0, <2.0",
"geopy",
"greenlet<=3.5.3, >=3.5.3",
"greenlet<=3.5.4, >=3.5.4",
"gunicorn>=26.0.0, <27; sys_platform != 'win32'",
"hashids>=1.3.1, <2",
# holidays>=0.45 required for security fix
@@ -90,7 +90,7 @@ dependencies = [
"paramiko>=3.4.0, <4.0", # 4.0 removed DSSKey, still referenced by sshtunnel
"pgsanity",
"Pillow>=11.0.0, <13",
"polyline>=2.0.0, <3.0",
"polyline>=2.0.4, <3.0",
"pydantic>=2.8.0",
"pyparsing>=3.3.2, <4",
"python-dateutil",
@@ -122,14 +122,14 @@ dependencies = [
[project.optional-dependencies]
athena = ["pyathena[pandas]>=2, <4"]
athena = ["pyathena[pandas]>=3.35.2, <4"]
aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
bigquery = [
"pandas-gbq>=0.35.0",
"sqlalchemy-bigquery>=1.17.0",
"google-cloud-bigquery>=3.42.2",
]
clickhouse = ["clickhouse-connect>=1.4.2, <2.0"]
clickhouse = ["clickhouse-connect>=1.6.0, <2.0"]
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
crate = ["sqlalchemy-cratedb>=0.41.0, <1"]
d1 = [
@@ -139,7 +139,7 @@ d1 = [
]
databend = ["databend-sqlalchemy>=0.5.5, <1.0"]
databricks = [
"databricks-sql-connector>=4.2.6, <4.4.0",
"databricks-sql-connector>=4.4.0, <4.5.0",
"databricks-sqlalchemy==1.0.5",
]
datafusion = ["flightsql-dbapi>=0.2.2, <0.3"]
@@ -173,7 +173,7 @@ hana = ["hdbcli==2.29.25", "sqlalchemy_hana==3.0.3"]
hive = [
"pyhive[hive_pure_sasl]>=0.7.0",
"tableschema",
"thrift>=0.23.0, <1.0.0",
"thrift>=0.24.0, <1.0.0",
"thrift_sasl>=0.4.3, < 1.0.0",
]
impala = ["impyla>=0.24.0, <0.25"]
@@ -196,7 +196,7 @@ playwright = ["playwright>=1.61.0, <2"]
postgres = ["psycopg2-binary==2.9.12"]
presto = ["pyhive[presto]>=0.6.5"]
trino = ["trino>=0.338.0"]
prophet = ["prophet>=1.1.6, <2"]
prophet = ["prophet>=1.3.0, <2"]
redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"]
risingwave = ["sqlalchemy-risingwave"]
shillelagh = ["shillelagh[all]>=1.4.4, <2"]
@@ -206,7 +206,7 @@ sqlite = ["syntaqlite>=0.7.0,<0.8.0"]
spark = [
"pyhive[hive_pure_sasl]>=0.7",
"tableschema",
"thrift>=0.23.0, <1",
"thrift>=0.24.0, <1",
]
tdengine = [
"taospy>=2.8.9",

View File

@@ -46,7 +46,7 @@ cachelib==0.13.0
# via
# flask-caching
# flask-session
cachetools==7.1.4
cachetools==7.1.6
# via apache-superset (pyproject.toml)
cattrs==25.1.1
# via requests-cache
@@ -86,7 +86,7 @@ colorama==0.4.6
# flask-appbuilder
cron-descriptor==1.4.5
# via apache-superset (pyproject.toml)
croniter==6.2.2
croniter==6.2.4
# via apache-superset (pyproject.toml)
cryptography==49.0.0
# via
@@ -166,7 +166,7 @@ google-auth==2.53.0
# via
# -r requirements/base.in
# shillelagh
greenlet==3.5.3
greenlet==3.5.4
# via
# apache-superset (pyproject.toml)
# shillelagh
@@ -291,7 +291,7 @@ pillow==12.3.0
# via apache-superset (pyproject.toml)
platformdirs==4.3.8
# via requests-cache
polyline==2.0.2
polyline==2.0.4
# via apache-superset (pyproject.toml)
prison==0.2.1
# via flask-appbuilder

View File

@@ -101,7 +101,7 @@ cachelib==0.13.0
# -c requirements/base-constraint.txt
# flask-caching
# flask-session
cachetools==7.1.4
cachetools==7.1.6
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -178,7 +178,7 @@ cron-descriptor==1.4.5
# via
# -c requirements/base-constraint.txt
# apache-superset
croniter==6.2.2
croniter==6.2.4
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -377,7 +377,7 @@ googleapis-common-protos==1.66.0
# via
# google-api-core
# grpcio-status
greenlet==3.5.3
greenlet==3.5.4
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -687,7 +687,7 @@ pluggy==1.5.0
# via pytest
polib==1.2.0
# via apache-superset
polyline==2.0.2
polyline==2.0.4
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -703,7 +703,7 @@ prompt-toolkit==3.0.51
# via
# -c requirements/base-constraint.txt
# click-repl
prophet==1.2.0
prophet==1.3.0
# via apache-superset
proto-plus==1.25.0
# via google-api-core

View File

@@ -35,7 +35,7 @@ acquire_rat_jar () {
wget --quiet ${URL} -O "$JAR_DL" && mv "$JAR_DL" "$JAR"
else
printf "You do not have curl or wget installed, please install rat manually.\n"
exit -1
exit 255
fi
fi
@@ -44,7 +44,7 @@ acquire_rat_jar () {
# We failed to download
rm "$JAR"
printf "Our attempt to download rat locally to ${JAR} failed. Please install rat manually.\n"
exit -1
exit 255
fi
printf "Done downloading.\n"
}

View File

@@ -163,10 +163,10 @@ do
# Iterate through the components of the version strings
for (( j=0; j<${#THIS_TAG_NAME_ARRAY[@]}; j++ )); do
echo "Comparing ${THIS_TAG_NAME_ARRAY[$j]} to ${LATEST_RELEASE_TAG_ARRAY[$j]}"
if [[ $((THIS_TAG_NAME_ARRAY[$j])) > $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
if [[ $((THIS_TAG_NAME_ARRAY[$j])) -gt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
compare_result="greater"
break
elif [[ $((THIS_TAG_NAME_ARRAY[$j])) < $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
elif [[ $((THIS_TAG_NAME_ARRAY[$j])) -lt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
compare_result="lesser"
break
fi

View File

@@ -216,6 +216,12 @@ test('should render the error', async () => {
.spyOn(SupersetClient, 'post')
.mockRejectedValue(new Error('Something went wrong'));
await waitForRender();
// The error is wrapped in an Alert component with a stable headline and the
// raw error text in the description — no more bare ``<pre>`` elements.
expect(await screen.findByRole('alert')).toBeVisible();
expect(
await screen.findByText('Failed to load drill-to-detail rows'),
).toBeVisible();
expect(screen.getByText('Error: Something went wrong')).toBeInTheDocument();
});

View File

@@ -42,6 +42,7 @@ import BooleanCell from '@superset-ui/core/components/Table/cell-renderers/Boole
import NullCell from '@superset-ui/core/components/Table/cell-renderers/NullCell';
import TimeCell from '@superset-ui/core/components/Table/cell-renderers/TimeCell';
import { EmptyState, Loading } from '@superset-ui/core/components';
import { Alert } from '@apache-superset/core/components';
import { getDatasourceSamples } from 'src/components/Chart/chartAction';
import Table, {
ColumnsType,
@@ -362,13 +363,18 @@ export default function DrillDetailPane({
if (responseError) {
// Render error if page download failed
tableContent = (
<pre
<div
css={css`
margin-top: ${theme.sizeUnit * 4}px;
`}
>
{responseError}
</pre>
<Alert
type="error"
showIcon
message={t('Failed to load drill-to-detail rows')}
description={responseError}
/>
</div>
);
} else if (bootstrapping) {
// Render loading if first page hasn't loaded

View File

@@ -50,6 +50,7 @@ const DISABLED_REASONS = {
DATABASE: t(
'Drill to detail is disabled for this database. Change the database settings to enable it.',
),
DATASOURCE: t('Drill to detail is not available for this datasource type.'),
NO_AGGREGATIONS: t(
'Drill to detail is disabled because this chart does not group data by dimension value.',
),
@@ -116,6 +117,17 @@ export const useDrillDetailMenuItems = ({
datasources[formData.datasource]?.database?.disable_drill_to_detail,
);
// Capability flag on the datasource itself. Datasources that don't model
// raw rows (e.g. semantic views) opt out via ``supports_drill_to_detail``
// in the explore data payload.
const datasourceSupportsDrillToDetail = useSelector<
RootState,
boolean | undefined
>(
({ datasources }) =>
datasources[formData.datasource]?.supports_drill_to_detail,
);
const openModal = useCallback(
(filters: BinaryQueryObjectFilterClause[], event: MouseEvent) => {
onClick(event);
@@ -158,7 +170,10 @@ export const useDrillDetailMenuItems = ({
let drillDisabled;
let drillByDisabled;
if (drillToDetailDisabled) {
if (datasourceSupportsDrillToDetail === false) {
drillDisabled = DISABLED_REASONS.DATASOURCE;
drillByDisabled = DISABLED_REASONS.DATASOURCE;
} else if (drillToDetailDisabled) {
drillDisabled = DISABLED_REASONS.DATABASE;
drillByDisabled = DISABLED_REASONS.DATABASE;
} else if (handlesDimensionContextMenu) {

View File

@@ -444,3 +444,45 @@ test('context menu renders <NULL> for null dimension values', async () => {
await expectDrillToDetailByEnabled();
await expectDrillToDetailByDimension(filterNull);
});
const buildStateWithUnsupportedDatasource = () => {
const baseState = getMockStoreWithNativeFilters().getState();
const datasourceKey = defaultFormData.datasource as string;
return {
...baseState,
datasources: {
...baseState.datasources,
[datasourceKey]: {
...baseState.datasources[datasourceKey],
supports_drill_to_detail: false,
},
},
};
};
test('dropdown menu when datasource opts out via supports_drill_to_detail=false', async () => {
cleanup();
render(<MockRenderChart formData={defaultFormData} />, {
useRouter: true,
useRedux: true,
initialState: buildStateWithUnsupportedDatasource(),
});
await expectDrillToDetailDisabled(
'Drill to detail is not available for this datasource type.',
);
await expectNoDrillToDetailBy();
});
test('context menu when datasource opts out via supports_drill_to_detail=false', async () => {
cleanup();
render(<MockRenderChart formData={defaultFormData} isContextMenu />, {
useRouter: true,
useRedux: true,
initialState: buildStateWithUnsupportedDatasource(),
});
const message = 'Drill to detail is not available for this datasource type.';
await expectDrillToDetailDisabled(message);
await expectDrillToDetailByDisabled(message);
});

View File

@@ -62,6 +62,7 @@ export function ErrorMessageWithStackTrace({
fallback,
compact,
closable = true,
errorMitigationFunction,
}: Props) {
// Check if a custom error message component was registered for this message
if (error) {
@@ -77,6 +78,7 @@ export function ErrorMessageWithStackTrace({
error={error}
source={source}
subtitle={subtitle}
errorMitigationFunction={errorMitigationFunction}
/>
);
}

View File

@@ -20,7 +20,7 @@
import * as reduxHooks from 'react-redux';
import { Provider } from 'react-redux';
import { createStore, Store } from 'redux';
import { render, waitFor } from 'spec/helpers/testing-library';
import { act, render, waitFor } from 'spec/helpers/testing-library';
import { ErrorLevel, ErrorSource, ErrorTypeEnum } from '@superset-ui/core';
import { reRunQuery } from 'src/SqlLab/actions/sqlLab';
import { triggerQuery } from 'src/components/Chart/chartAction';
@@ -166,15 +166,55 @@ describe('OAuth2RedirectMessage Component', () => {
render(setup());
simulateBroadcastMessage({ tabId: 'tabId' });
simulateStorageMessage({ tabId: 'tabId' });
await waitFor(() => {
expect(reRunQuery).toHaveBeenCalledWith({ sql: 'SELECT * FROM table' });
});
expect(reRunQuery).toHaveBeenCalledTimes(1);
});
test('dispatches reRunQuery action when storage event has matching tab ID', async () => {
render(setup());
simulateStorageMessage({ tabId: 'tabId' });
await waitFor(() => {
expect(reRunQuery).toHaveBeenCalledWith({ sql: 'SELECT * FROM table' });
});
});
test('dispatches reRunQuery action when storage event has matching tab ID', async () => {
render(setup());
test('waits for the SQL Lab query before consuming the completion', async () => {
const initialState = {
sqlLab: {
queries: {},
queryEditors: [{ id: 'editor-id', latestQueryId: 'query-id' }],
tabHistory: ['editor-id'],
},
explore: { slice: null },
charts: {},
dashboardInfo: {},
};
const delayedQueryStore = createStore(
(state: typeof initialState = initialState, action) =>
action.type === 'load-query'
? {
...state,
sqlLab: {
...state.sqlLab,
queries: { 'query-id': { sql: 'SELECT * FROM table' } },
},
}
: state,
);
render(setup({}, delayedQueryStore));
simulateBroadcastMessage({ tabId: 'tabId' });
expect(reRunQuery).not.toHaveBeenCalled();
act(() => {
delayedQueryStore.dispatch({ type: 'load-query' });
});
simulateStorageMessage({ tabId: 'tabId' });
await waitFor(() => {
@@ -234,4 +274,22 @@ describe('OAuth2RedirectMessage Component', () => {
]);
});
});
test('runs scoped mitigation once instead of CRUD invalidation', async () => {
const errorMitigationFunction = jest.fn();
render(
setup({
source: 'crud' as ErrorSource,
errorMitigationFunction,
}),
);
simulateBroadcastMessage({ tabId: 'tabId' });
simulateStorageMessage({ tabId: 'tabId' });
await waitFor(() => {
expect(errorMitigationFunction).toHaveBeenCalledTimes(1);
});
expect(api.util.invalidateTags).not.toHaveBeenCalled();
});
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
@@ -58,15 +58,16 @@ interface OAuth2RedirectExtra {
*
* After the token has been stored, the opened tab will broadcast a message to the
* original tab and close itself. This component, running on the original tab, listens
* on a same-origin BroadcastChannel and re-runs the query for the user once it
* receives the success message — be it in SQL Lab, Explore, or a dashboard. Both tabs
* share a "tab ID" (a UUID generated by the backend) which is echoed back through the
* channel so the original tab only reacts to its own OAuth2 flow.
* for same-origin BroadcastChannel and storage notifications and re-runs the query
* for the user once it receives the success message — be it in SQL Lab, Explore, or
* a dashboard. Both tabs share a "tab ID" (a UUID generated by the backend) which is
* echoed back so the original tab only reacts to its own OAuth2 flow.
*/
export function OAuth2RedirectMessage({
error,
source,
closable,
errorMitigationFunction,
}: ErrorMessageComponentProps<OAuth2RedirectExtra>) {
const { extra, level } = error;
@@ -103,13 +104,17 @@ export function OAuth2RedirectMessage({
);
const dispatch = useDispatch();
const lastHandledTabIdRef = useRef<string>();
useEffect(() => {
const handleOAuthComplete = (tabId?: string) => {
if (tabId !== extra.tab_id) {
if (tabId !== extra.tab_id || tabId === lastHandledTabIdRef.current) {
return;
}
if (source === 'sqllab' && query) {
if (errorMitigationFunction) {
errorMitigationFunction();
} else if (source === 'sqllab' && query) {
dispatch(reRunQuery(query));
} else if (source === 'explore') {
dispatch(triggerQuery(true, chartId));
@@ -123,7 +128,11 @@ export function OAuth2RedirectMessage({
'Tables',
]),
);
} else {
return;
}
lastHandledTabIdRef.current = tabId;
};
const channel =
@@ -156,7 +165,16 @@ export function OAuth2RedirectMessage({
window.removeEventListener('storage', handleStorage);
channel?.close();
};
}, [source, extra.tab_id, dispatch, query, chartId, chartList, dashboardId]);
}, [
source,
extra.tab_id,
dispatch,
query,
chartId,
chartList,
dashboardId,
errorMitigationFunction,
]);
const body = (
<p>

View File

@@ -27,6 +27,7 @@ export type ErrorMessageComponentProps<ExtraType = Record<string, any> | null> =
subtitle?: ReactNode;
compact?: boolean;
closable?: boolean;
errorMitigationFunction?: () => void;
};
export type ErrorMessageComponent = ComponentType<ErrorMessageComponentProps>;

View File

@@ -0,0 +1,38 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
import DeleteComponentButton from './DeleteComponentButton';
test('exposes an accessible name without rendering visible label text', () => {
render(<DeleteComponentButton onDelete={jest.fn()} />);
expect(
screen.getByRole('button', { name: 'Delete component' }),
).toBeInTheDocument();
expect(screen.queryByText('Delete component')).not.toBeInTheDocument();
});
test('calls onDelete when clicked', () => {
const onDelete = jest.fn();
render(<DeleteComponentButton onDelete={onDelete} />);
fireEvent.click(screen.getByRole('button', { name: 'Delete component' }));
expect(onDelete).toHaveBeenCalledTimes(1);
});

View File

@@ -18,6 +18,7 @@
*/
import { MouseEventHandler, FC } from 'react';
import { t } from '@apache-superset/core/translation';
import { Icons } from '@superset-ui/core/components/Icons';
import type { IconType } from '@superset-ui/core/components/Icons/types';
import IconButton from './IconButton';
@@ -33,6 +34,8 @@ const DeleteComponentButton: FC<DeleteComponentButtonProps> = ({
}) => (
<IconButton
onClick={onDelete}
label={t('Delete component')}
hideVisibleLabel
icon={<Icons.DeleteOutlined iconSize={iconSize ?? 'l'} />}
/>
);

View File

@@ -73,3 +73,17 @@ test('renders the provided label', () => {
expect(screen.getByText('My Label')).toBeInTheDocument();
});
test('hideVisibleLabel suppresses visible text but keeps the accessible name', () => {
render(
<IconButton
icon={icon}
onClick={jest.fn()}
label="My Label"
hideVisibleLabel
/>,
);
expect(screen.queryByText('My Label')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'My Label' })).toBeInTheDocument();
});

View File

@@ -22,6 +22,7 @@ import { styled, SupersetTheme } from '@apache-superset/core/theme';
interface IconButtonProps extends HTMLAttributes<HTMLButtonElement> {
icon: JSX.Element;
label?: string;
hideVisibleLabel?: boolean;
onClick: MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
'data-test'?: string;
@@ -63,6 +64,7 @@ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
{
icon,
label,
hideVisibleLabel,
onClick,
onKeyDown,
disabled,
@@ -75,6 +77,7 @@ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
{...rest}
ref={ref}
type="button"
aria-label={label}
isDisabled={disabled}
aria-disabled={disabled}
data-test={dataTest}
@@ -91,7 +94,7 @@ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
}}
>
{icon}
{label && <StyledSpan>{label}</StyledSpan>}
{label && !hideVisibleLabel && <StyledSpan>{label}</StyledSpan>}
</StyledButton>
),
);

View File

@@ -566,3 +566,31 @@ test('should pass filterState from dataMask to ChartContainer', () => {
mockFilterState,
);
});
test('should pass chartStackTrace to ChartContainer so dashboard chart errors stay expandable', () => {
// Regression guard for #31858: the dashboard chart wrapper stopped forwarding
// the stack trace, so failed charts rendered a flat error with no "See more"
// affordance while the same error in Explore stayed expandable.
const stackTrace = 'Traceback (most recent call last): ValueError: boom';
setup(
{},
{
...defaultState,
charts: {
...defaultState.charts,
[queryId]: {
...defaultState.charts[queryId],
chartStatus: 'failed',
chartAlert: 'Something went wrong',
chartStackTrace: stackTrace,
},
},
},
);
expect(capturedChartContainerProps).toHaveProperty(
'chartStackTrace',
stackTrace,
);
});

View File

@@ -789,6 +789,7 @@ const Chart = (props: ChartProps) => {
chartAlert={chart.chartAlert ?? undefined}
chartId={props.id}
chartStatus={chartStatus ?? undefined}
chartStackTrace={chart.chartStackTrace ?? undefined}
datasource={datasource}
dashboardId={props.dashboardId}
initialValues={EMPTY_OBJECT}

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import React from 'react';
import { fireEvent, render } from 'spec/helpers/testing-library';
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
import IconButton from 'src/dashboard/components/IconButton';
@@ -200,6 +200,15 @@ test('should call deleteComponent when deleted', () => {
expect(deleteComponent).toHaveBeenCalledTimes(1);
});
test('settings IconButton exposes an accessible name without visible label text', () => {
setup({ component: columnWithoutChildren, editMode: true });
expect(
screen.getByRole('button', { name: 'Column settings' }),
).toBeInTheDocument();
expect(screen.queryByText('Column settings')).not.toBeInTheDocument();
});
test('should pass its own width as availableColumnCount to children', () => {
const { getByTestId } = setup();
expect(getByTestId('mock-dashboard-component')).toHaveTextContent(

View File

@@ -247,6 +247,8 @@ const Column = (props: ColumnProps) => {
/>
<IconButton
onClick={() => handleChangeFocus(true)}
label={t('Column settings')}
hideVisibleLabel
icon={<Icons.SettingOutlined iconSize="m" />}
/>
</HoverMenu>

View File

@@ -145,7 +145,9 @@ describe('Header', () => {
const deleteComponent = jest.fn();
setup({ editMode: true, deleteComponent });
const trashButton = screen.getByRole('button', { name: 'delete' });
const trashButton = screen.getByRole('button', {
name: 'Delete component',
});
fireEvent.click(trashButton);
expect(deleteComponent).toHaveBeenCalledTimes(1);

View File

@@ -240,6 +240,15 @@ test('should call deleteComponent when deleted', () => {
expect(deleteComponent).toHaveBeenCalledTimes(1);
});
test('settings IconButton exposes an accessible name without visible label text', () => {
setup({ component: rowWithoutChildren, editMode: true });
expect(
screen.getByRole('button', { name: 'Row settings' }),
).toBeInTheDocument();
expect(screen.queryByText('Row settings')).not.toBeInTheDocument();
});
test('should pass appropriate availableColumnCount to children', () => {
const { getByTestId } = setup();
expect(getByTestId('mock-dashboard-component')).toHaveTextContent(

View File

@@ -293,6 +293,8 @@ const Row = memo((props: RowProps) => {
<DeleteComponentButton onDelete={handleDeleteComponent} />
<IconButton
onClick={() => handleChangeFocus(true)}
label={t('Row settings')}
hideVisibleLabel
icon={<Icons.SettingOutlined iconSize="l" />}
/>
</HoverMenu>

View File

@@ -232,6 +232,10 @@ export type Datasource = Dataset & {
// Populated by the dashboard datasets API alongside ``type``; declared here
// so callers can rely on structural typing instead of casting.
datasource_type?: DatasourceType;
/** False when the datasource can't return row samples (e.g. semantic views). */
supports_samples?: boolean;
/** False when the datasource can't answer drill-to-detail requests. */
supports_drill_to_detail?: boolean;
};
export type DatasourcesState = {
[key: string]: Datasource;

View File

@@ -241,25 +241,44 @@ export const DataTablesPane = ({
}
}, [resultsTabFallback]);
// Hide the Samples tab for datasources that don't expose raw rows
// (e.g. semantic views). The check is intentionally ``=== false`` so that
// datasources from older backends that don't send the flag still show the
// tab and preserve current behavior.
const showSamplesTab = datasource?.supports_samples !== false;
// If the datasource swaps to one that doesn't support samples while the
// Samples tab is active (e.g. the user picks a semantic view), the tab
// disappears from ``tabItems`` and ``activeTabKey`` is orphaned. Fall back
// to Results so the panel keeps rendering content.
useEffect(() => {
if (!showSamplesTab && activeTabKey === ResultTypes.Samples) {
setActiveTabKey(ResultTypes.Results);
}
}, [showSamplesTab, activeTabKey]);
const tabItems = [
...queryResultsPanes,
{
key: ResultTypes.Samples,
label: t('Samples'),
children: (
<StyledDiv>
<SamplesPane
datasource={datasource}
queryFormData={queryFormData}
queryForce={queryForce}
isRequest={isRequest.samples}
setForceQuery={setForceQuery}
isVisible={ResultTypes.Samples === activeTabKey}
canDownload={canDownload}
/>
</StyledDiv>
),
},
...(showSamplesTab
? [
{
key: ResultTypes.Samples,
label: t('Samples'),
children: (
<StyledDiv>
<SamplesPane
datasource={datasource}
queryFormData={queryFormData}
queryForce={queryForce}
isRequest={isRequest.samples}
setForceQuery={setForceQuery}
isVisible={ResultTypes.Samples === activeTabKey}
canDownload={canDownload}
/>
</StyledDiv>
),
},
]
: []),
];
return (

View File

@@ -21,6 +21,7 @@ import { t } from '@apache-superset/core/translation';
import { ensureIsArray } from '@superset-ui/core';
import { datasetLabelLower } from 'src/features/semanticLayers/label';
import { styled } from '@apache-superset/core/theme';
import { Alert } from '@apache-superset/core/components';
import { EmptyState, Loading } from '@superset-ui/core/components';
import { GenericDataType } from '@apache-superset/core/common';
import { GridTable } from 'src/components/GridTable';
@@ -35,7 +36,7 @@ import {
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
import { SamplesPaneProps } from '../types';
const Error = styled.pre`
const ErrorAlertWrapper = styled.div`
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
`;
@@ -155,7 +156,14 @@ export const SamplesPane = ({
rowLimitOptions={ROW_LIMIT_OPTIONS}
onRowLimitChange={handleRowLimitChange}
/>
<Error>{responseError}</Error>
<ErrorAlertWrapper>
<Alert
type="error"
showIcon
message={t('Failed to load samples')}
description={responseError}
/>
</ErrorAlertWrapper>
</>
);
}

View File

@@ -27,13 +27,14 @@ import {
QueryData,
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { Alert } from '@apache-superset/core/components';
import { EmptyState, Loading } from '@superset-ui/core/components';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
import { ResultsPaneProps, QueryResultInterface } from '../types';
import { SingleQueryResultPane } from './SingleQueryResultPane';
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
const Error = styled.pre`
const ErrorAlertWrapper = styled.div`
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
`;
@@ -199,7 +200,14 @@ export const useResultsPane = ({
isLoading={false}
canDownload={canDownload}
/>
<Error>{responseError}</Error>
<ErrorAlertWrapper>
<Alert
type="error"
showIcon
message={t('Failed to load results')}
description={responseError}
/>
</ErrorAlertWrapper>
</>
);
return Array(queryCount).fill(err);

View File

@@ -19,7 +19,12 @@
import fetchMock from 'fetch-mock';
import { FeatureFlag } from '@superset-ui/core';
import * as copyUtils from 'src/utils/copy';
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import {
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
import { DataTablesPane } from '..';
@@ -89,6 +94,48 @@ describe('DataTablesPane', () => {
expect(await screen.findByLabelText('Collapse data panel')).toBeVisible();
});
test('Hides Samples tab when datasource opts out via supports_samples=false', async () => {
const props = createDataTablesPaneProps(0);
const propsWithoutSamples = {
...props,
datasource: { ...props.datasource, supports_samples: false },
};
render(<DataTablesPane {...propsWithoutSamples} />, { useRedux: true });
expect(await screen.findByText('Results')).toBeVisible();
expect(screen.queryByText('Samples')).not.toBeInTheDocument();
});
test('Falls back to Results when active Samples tab disappears mid-session', async () => {
// Regression for codeant Major finding on PR #41509: a datasource swap
// that hides the Samples tab while it was the active tab used to leave
// ``activeTabKey === 'samples'`` orphaned, rendering a blank panel.
const props = createDataTablesPaneProps(0);
const { rerender } = render(<DataTablesPane {...props} />, {
useRedux: true,
});
// Open the panel and pick the Samples tab.
userEvent.click(screen.getByLabelText('Expand data panel'));
userEvent.click(await screen.findByText('Samples'));
expect(await screen.findByLabelText('Collapse data panel')).toBeVisible();
// Swap to a datasource that doesn't support samples (e.g. a semantic
// view). The Samples tab should disappear and the panel should land on
// Results with content still rendered.
rerender(
<DataTablesPane
{...props}
datasource={{ ...props.datasource, supports_samples: false }}
/>,
);
await waitFor(() => {
expect(screen.queryByText('Samples')).not.toBeInTheDocument();
});
expect(screen.getByText('Results')).toBeVisible();
// Panel stays expanded and renders Results content rather than going blank.
expect(screen.getByLabelText('Collapse data panel')).toBeVisible();
});
test('Should copy data table content correctly', async () => {
fetchMock.post(
'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D',

View File

@@ -84,10 +84,14 @@ describe('SamplesPane', () => {
const props = createSamplesPaneProps({
datasourceId: 36,
});
const { findByText } = render(<SamplesPane {...props} />, {
const { findByText, findByRole } = render(<SamplesPane {...props} />, {
useRedux: true,
});
// The error is now rendered inside an Alert component, with a clear
// headline message and the raw error text as the description.
expect(await findByRole('alert')).toBeVisible();
expect(await findByText('Failed to load samples')).toBeVisible();
expect(await findByText('Error: Bad request')).toBeVisible();
});

View File

@@ -74,6 +74,18 @@ export type Datasource = Dataset & {
schema?: string;
is_sqllab_view?: boolean;
extra?: string | object;
/**
* False when the datasource (e.g. a semantic view) doesn't model raw rows
* and therefore can't return a row sample. Defaults to true on the server
* side; missing here means the explore UI keeps current behavior.
*/
supports_samples?: boolean;
/**
* False when the datasource doesn't model raw rows and therefore can't
* answer a drill-to-detail query. Tracked separately from
* ``supports_samples`` so the two capabilities can diverge.
*/
supports_drill_to_detail?: boolean;
};
export interface ExplorePageInitialData {

View File

@@ -35,6 +35,5 @@ export const Basic: StoryFn<typeof DatasetPanel> = args => (
Basic.args = {
tableName: 'example_table',
loading: false,
hasError: false,
columnList: exampleColumns,
};

View File

@@ -77,7 +77,6 @@ test('View Dataset opens a single-prefixed URL under a subdirectory deployment',
render(
<DatasetPanel
tableName="example_table"
hasError={false}
columnList={exampleColumns}
loading={false}
datasets={datasetWith(`${APP_ROOT}/explore/?datasource=1__table`)}
@@ -97,7 +96,6 @@ test('View Dataset passes an external explore_url through unprefixed', async ()
render(
<DatasetPanel
tableName="example_table"
hasError={false}
columnList={exampleColumns}
loading={false}
datasets={datasetWith('https://external.example.com/custom-endpoint')}

View File

@@ -17,10 +17,12 @@
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import { ErrorTypeEnum } from '@superset-ui/core';
import DatasetPanel, {
REFRESHING,
tableColumnDefinition,
COLUMN_TITLE,
ERROR_TITLE,
} from 'src/features/datasets/AddDataset/DatasetPanel/DatasetPanel';
import { exampleColumns, exampleDataset } from './fixtures';
import { ITableColumn } from './types';
@@ -31,8 +33,6 @@ import {
SELECT_TABLE_TITLE,
NO_COLUMNS_TITLE,
NO_COLUMNS_DESCRIPTION,
ERROR_TITLE,
ERROR_DESCRIPTION,
} from './MessageContent';
jest.mock(
@@ -47,7 +47,7 @@ jest.mock(
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('DatasetPanel', () => {
test('renders a blank state DatasetPanel', () => {
render(<DatasetPanel hasError={false} columnList={[]} loading={false} />, {
render(<DatasetPanel columnList={[]} loading={false} />, {
useRouter: true,
});
@@ -70,17 +70,9 @@ describe('DatasetPanel', () => {
});
test('renders a no columns screen', () => {
render(
<DatasetPanel
tableName="Name"
hasError={false}
columnList={[]}
loading={false}
/>,
{
useRouter: true,
},
);
render(<DatasetPanel tableName="Name" columnList={[]} loading={false} />, {
useRouter: true,
});
const blankDatasetImg = screen.getByRole('img', { name: /empty/i });
expect(blankDatasetImg).toBeVisible();
@@ -91,17 +83,9 @@ describe('DatasetPanel', () => {
});
test('renders a loading screen', () => {
render(
<DatasetPanel
tableName="Name"
hasError={false}
columnList={[]}
loading
/>,
{
useRouter: true,
},
);
render(<DatasetPanel tableName="Name" columnList={[]} loading />, {
useRouter: true,
});
const loadingIndicator = screen.getByTestId('loading-indicator');
expect(loadingIndicator).toBeVisible();
@@ -113,7 +97,12 @@ describe('DatasetPanel', () => {
render(
<DatasetPanel
tableName="Name"
hasError
error={{
error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR,
extra: null,
level: 'error',
message: 'Structured backend failure',
}}
columnList={[]}
loading={false}
/>,
@@ -124,8 +113,9 @@ describe('DatasetPanel', () => {
const errorTitle = screen.getByText(ERROR_TITLE);
expect(errorTitle).toBeVisible();
const errorDescription = screen.getByText(ERROR_DESCRIPTION);
const errorDescription = screen.getByText('Structured backend failure');
expect(errorDescription).toBeVisible();
expect(screen.getByTitle('Name')).toHaveStyle({ position: 'relative' });
});
test('renders a table with columns displayed', async () => {
@@ -133,7 +123,6 @@ describe('DatasetPanel', () => {
render(
<DatasetPanel
tableName={tableName}
hasError={false}
columnList={exampleColumns}
loading={false}
/>,
@@ -159,7 +148,6 @@ describe('DatasetPanel', () => {
render(
<DatasetPanel
tableName="example_table"
hasError={false}
columnList={exampleColumns}
loading={false}
datasets={exampleDataset}

View File

@@ -21,11 +21,13 @@ import { Alert } from '@apache-superset/core/components';
import { css, styled } from '@apache-superset/core/theme';
import { Icons } from '@superset-ui/core/components/Icons';
import { Loading } from '@superset-ui/core/components';
import type { SupersetError } from '@superset-ui/core';
import Table, {
ColumnsType,
TableSize,
} from '@superset-ui/core/components/Table';
import { DatasetObject } from 'src/features/datasets/AddDataset/types';
import { ErrorMessageWithStackTrace } from 'src/components';
import { openInNewTab, stripAppRoot } from 'src/utils/navigationUtils';
import { ITableColumn } from './types';
import MessageContent from './MessageContent';
@@ -146,6 +148,10 @@ const TableScrollContainer = styled.div`
right: 0;
`;
const ErrorContainer = styled.div`
padding: 0 ${({ theme }) => theme.sizeUnit * 6}px;
`;
const StyledAlert = styled(Alert)`
${({ theme }) => `
border: 1px solid ${theme.colorInfoText};
@@ -167,6 +173,7 @@ const StyledAlert = styled(Alert)`
export const REFRESHING = t('Refreshing columns');
export const COLUMN_TITLE = t('Table columns');
export const ERROR_TITLE = t('An Error Occurred');
const pageSizeOptions = ['5', '10', '15', '25'];
const DEFAULT_PAGE_SIZE = 25;
@@ -201,9 +208,13 @@ export interface IDatasetPanelProps {
*/
columnList: ITableColumn[];
/**
* Boolean indicating if there is an error state
* Error returned while loading the table metadata
*/
hasError: boolean;
error?: SupersetError;
/**
* Function used to retry loading the table metadata after error mitigation
*/
errorMitigationFunction?: () => void;
/**
* Boolean indicating if the component is in a loading state
*/
@@ -256,11 +267,11 @@ const DatasetPanel = ({
tableName,
columnList,
loading,
hasError,
error,
errorMitigationFunction,
datasets,
}: IDatasetPanelProps) => {
const hasColumns = Boolean(columnList?.length > 0);
const datasetNames = datasets?.map(dataset => dataset.table_name);
const hasColumns = columnList.length > 0;
const tableWithDataset = datasets?.find(
dataset => dataset.table_name === tableName,
);
@@ -278,7 +289,19 @@ const DatasetPanel = ({
);
}
if (!loading) {
if (!loading && tableName && hasColumns && !hasError) {
if (error) {
component = (
<ErrorContainer>
<ErrorMessageWithStackTrace
error={error}
errorMitigationFunction={errorMitigationFunction}
source="crud"
subtitle={error.message}
title={ERROR_TITLE}
/>
</ErrorContainer>
);
} else if (tableName && hasColumns) {
component = (
<>
<StyledTitle title={COLUMN_TITLE}>{COLUMN_TITLE}</StyledTitle>
@@ -312,13 +335,7 @@ const DatasetPanel = ({
</>
);
} else {
component = (
<MessageContent
hasColumns={hasColumns}
hasError={hasError}
tableName={tableName}
/>
);
component = <MessageContent tableName={tableName} />;
}
}
@@ -326,11 +343,12 @@ const DatasetPanel = ({
<>
{tableName && (
<>
{datasetNames?.includes(tableName) &&
renderExistingDatasetAlert(tableWithDataset)}
{tableWithDataset && renderExistingDatasetAlert(tableWithDataset)}
<StyledHeader
position={
!loading && hasColumns ? EPosition.RELATIVE : EPosition.ABSOLUTE
!loading && (hasColumns || error)
? EPosition.RELATIVE
: EPosition.ABSOLUTE
}
title={tableName || ''}
>

View File

@@ -16,8 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, waitFor } from 'spec/helpers/testing-library';
import { SupersetClient } from '@superset-ui/core';
import { act, render, screen, waitFor } from 'spec/helpers/testing-library';
import { ErrorTypeEnum, SupersetClient } from '@superset-ui/core';
import type { SupersetClientResponse } from '@superset-ui/core';
import {
DatabaseErrorMessage,
getErrorMessageComponentRegistry,
OAuth2RedirectMessage,
} from 'src/components/ErrorMessage';
import DatasetPanelWrapper from 'src/features/datasets/AddDataset/DatasetPanel';
jest.mock(
@@ -29,17 +35,29 @@ jest.mock(
),
);
const errorMessageRegistry = getErrorMessageComponentRegistry();
afterEach(() => {
errorMessageRegistry.remove(ErrorTypeEnum.GENERIC_BACKEND_ERROR);
errorMessageRegistry.remove(ErrorTypeEnum.OAUTH2_REDIRECT);
jest.restoreAllMocks();
});
const tableMetadataResponse = (
name: string,
columnName: string,
): SupersetClientResponse => ({
response: new Response(),
json: {
name,
columns: [{ name: columnName, type: 'INTEGER', longType: 'INTEGER' }],
},
});
test('fetches table metadata for schema-less database without schema', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: {
name: 'my_table',
columns: [{ name: 'id', type: 'INTEGER', longType: 'INTEGER' }],
},
} as any);
const getSpy = jest
.spyOn(SupersetClient, 'get')
.mockResolvedValue(tableMetadataResponse('my_table', 'id'));
render(
<DatasetPanelWrapper
@@ -58,3 +76,99 @@ test('fetches table metadata for schema-less database without schema', async ()
);
});
});
test('renders a fallback message for an unstructured metadata error', async () => {
jest.spyOn(SupersetClient, 'get').mockRejectedValue({
response: new Response('{}', {
status: 500,
headers: { 'Content-Type': 'application/json' },
}),
});
errorMessageRegistry.registerValue(
ErrorTypeEnum.GENERIC_BACKEND_ERROR,
DatabaseErrorMessage,
);
render(
<DatasetPanelWrapper
tableName="broken_table"
dbId={1}
database={{ supports_schemas: false }}
/>,
{ useRouter: true },
);
expect(
await screen.findByText('Unable to load columns for the selected table.'),
).toBeVisible();
});
test('retries only table metadata after matching OAuth completion', async () => {
const oauthError = {
error_type: ErrorTypeEnum.OAUTH2_REDIRECT,
message: 'OAuth authorization is required.',
extra: {
url: 'https://example.com/authorize',
tab_id: 'dataset-oauth-tab',
},
level: 'warning',
};
const getSpy = jest
.spyOn(SupersetClient, 'get')
.mockRejectedValueOnce({
response: new Response(JSON.stringify({ errors: [oauthError] }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
}),
})
.mockResolvedValueOnce(tableMetadataResponse('oauth_table', 'oauth_id'));
errorMessageRegistry.registerValue(
ErrorTypeEnum.OAUTH2_REDIRECT,
OAuth2RedirectMessage,
);
render(
<DatasetPanelWrapper
tableName="oauth_table"
dbId={1}
database={{ supports_schemas: false }}
/>,
{
initialState: {
charts: {},
dashboardInfo: {},
explore: {},
sqlLab: {
queries: {},
queryEditors: [],
tabHistory: [],
},
},
useRedux: true,
useRouter: true,
},
);
const authorizationLink = await screen.findByRole('link', {
name: /provide authorization/i,
});
expect(authorizationLink).toHaveAttribute(
'href',
'https://example.com/authorize',
);
expect(getSpy).toHaveBeenCalledTimes(1);
act(() => {
window.dispatchEvent(
new StorageEvent('storage', {
key: 'oauth2_auth_complete',
newValue: JSON.stringify({ tabId: 'dataset-oauth-tab' }),
}),
);
});
expect(await screen.findByText('oauth_id')).toBeVisible();
expect(getSpy).toHaveBeenCalledTimes(2);
expect(getSpy.mock.calls[1]).toEqual(getSpy.mock.calls[0]);
});

View File

@@ -65,27 +65,17 @@ export const NO_COLUMNS_TITLE = t('No table columns');
export const NO_COLUMNS_DESCRIPTION = t(
'This database table does not contain any data. Please select a different table.',
);
export const ERROR_TITLE = t('An Error Occurred');
export const ERROR_DESCRIPTION = t(
'Unable to load columns for the selected table. Please select a different table.',
);
interface MessageContentProps {
hasError: boolean;
tableName?: string | null;
hasColumns: boolean;
}
export const MessageContent = (props: MessageContentProps) => {
const { hasError, tableName, hasColumns } = props;
let currentImage: string | undefined = 'empty-dataset.svg';
const { tableName } = props;
let currentImage = 'empty-dataset.svg';
let currentTitle = SELECT_TABLE_TITLE;
let currentDescription = renderEmptyDescription();
if (hasError) {
currentTitle = ERROR_TITLE;
currentDescription = <>{ERROR_DESCRIPTION}</>;
currentImage = undefined;
} else if (tableName && !hasColumns) {
if (tableName) {
currentImage = 'no-columns.svg';
currentTitle = NO_COLUMNS_TITLE;
currentDescription = <>{NO_COLUMNS_DESCRIPTION}</>;

View File

@@ -16,9 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { t } from '@apache-superset/core/translation';
import { SupersetClient } from '@superset-ui/core';
import {
ErrorTypeEnum,
getClientErrorObject,
SupersetClient,
} from '@superset-ui/core';
import type { SupersetError } from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import { DatasetObject } from 'src/features/datasets/AddDataset/types';
import { addDangerToast } from 'src/components/MessageToasts/actions';
@@ -30,7 +35,7 @@ import { ITableColumn, IDatabaseTable, isIDatabaseTable } from './types';
/**
* Interface for the getTableMetadata API call
*/
interface IColumnProps {
interface TableMetadataRequest {
/**
* Unique id of the database
*/
@@ -43,6 +48,10 @@ interface IColumnProps {
* Name of the schema (optional for databases that don't support schemas)
*/
schema?: string | null;
/**
* Name of the catalog (optional for databases that don't support catalogs)
*/
catalog?: string | null;
}
export interface IDatasetPanelWrapperProps {
@@ -63,7 +72,7 @@ export interface IDatasetPanelWrapperProps {
* The selected database object (used to check engine capabilities)
*/
database?: Partial<DatabaseObject> | null;
setHasColumns?: Function;
setHasColumns?: (hasColumns: boolean) => void;
datasets?: DatasetObject[] | undefined;
}
@@ -78,74 +87,131 @@ const DatasetPanelWrapper = ({
}: IDatasetPanelWrapperProps) => {
const [columnList, setColumnList] = useState<ITableColumn[]>([]);
const [loading, setLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const tableNameRef = useRef(tableName);
const [error, setError] = useState<SupersetError>();
const requestIdRef = useRef(0);
const currentRequestRef = useRef<TableMetadataRequest>();
const supportsSchemas = database?.supports_schemas;
const getTableMetadata = async (props: IColumnProps) => {
const { dbId, tableName, schema } = props;
setLoading(true);
setHasColumns?.(false);
const path = `/api/v1/database/${dbId}/table_metadata/${toQueryString({
name: tableName,
catalog,
schema,
})}`;
try {
const response = await SupersetClient.get({
endpoint: path,
});
const getTableMetadata = useCallback(
async (props: TableMetadataRequest) => {
const { dbId, tableName, catalog, schema } = props;
requestIdRef.current += 1;
const requestId = requestIdRef.current;
setLoading(true);
setColumnList([]);
setError(undefined);
setHasColumns?.(false);
const path = `/api/v1/database/${dbId}/table_metadata/${toQueryString({
name: tableName,
catalog,
schema,
})}`;
try {
const response = await SupersetClient.get({
endpoint: path,
});
if (isIDatabaseTable(response?.json)) {
const table: IDatabaseTable = response.json as IDatabaseTable;
/**
* The user is able to click other table columns while the http call for last selected table column is made
* This check ensures we process the response that matches the last selected table name and ignore the others
*/
if (table.name === tableNameRef.current) {
if (requestId !== requestIdRef.current) {
return;
}
const table = isIDatabaseTable(response?.json)
? (response.json as IDatabaseTable)
: undefined;
if (table?.name === tableName) {
setColumnList(table.columns);
setHasColumns?.(table.columns.length > 0);
setHasError(false);
setError(undefined);
} else {
const message = t(
'The API response from %s does not match the IDatabaseTable interface.',
path,
);
setColumnList([]);
setHasColumns?.(false);
setError({
error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR,
extra: null,
level: 'error',
message,
});
addDangerToast(message);
logging.error(message);
}
} else {
setColumnList([]);
setHasColumns?.(false);
setHasError(true);
addDangerToast(
t(
'The API response from %s does not match the IDatabaseTable interface.',
path,
),
);
logging.error(
t(
'The API response from %s does not match the IDatabaseTable interface.',
path,
),
} catch (caughtError) {
const clientError = await getClientErrorObject(
caughtError as Parameters<typeof getClientErrorObject>[0],
);
if (requestId === requestIdRef.current) {
const parsedError = clientError.errors?.[0] ?? {
error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR,
extra: null,
level: 'error' as const,
message:
clientError.error ||
clientError.message ||
clientError.statusText ||
t('Unable to load columns for the selected table.'),
};
setColumnList([]);
setHasColumns?.(false);
setError(parsedError);
}
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
}
}
} catch (error) {
setColumnList([]);
setHasColumns?.(false);
setHasError(true);
} finally {
setLoading(false);
},
[setHasColumns],
);
const retryGetTableMetadata = useCallback(() => {
if (currentRequestRef.current) {
getTableMetadata(currentRequestRef.current);
}
};
}, [getTableMetadata]);
useEffect(() => {
tableNameRef.current = tableName;
const schemaRequired = database?.supports_schemas !== false;
const schemaRequired = supportsSchemas !== false;
if (tableName && dbId && (schema || !schemaRequired)) {
getTableMetadata({ tableName, dbId, schema: schema || undefined });
const request = {
tableName,
dbId,
catalog,
schema: schema || undefined,
};
currentRequestRef.current = request;
getTableMetadata(request);
} else if (currentRequestRef.current) {
currentRequestRef.current = undefined;
requestIdRef.current += 1;
setColumnList([]);
setError(undefined);
setHasColumns?.(false);
setLoading(false);
}
// getTableMetadata is a const and should not be in dependency array
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName, dbId, schema, database]);
return () => {
requestIdRef.current += 1;
};
}, [
tableName,
dbId,
catalog,
schema,
supportsSchemas,
getTableMetadata,
setHasColumns,
]);
return (
<DatasetPanel
columnList={columnList}
hasError={hasError}
error={error}
errorMitigationFunction={retryGetTableMetadata}
loading={loading}
tableName={tableName}
datasets={datasets}

View File

@@ -138,7 +138,6 @@ describe('DatasetLayout', () => {
<DatasetPanelComponent
tableName="large_table"
columnList={manyColumns}
hasError={false}
loading={false}
/>
}

View File

@@ -17,7 +17,7 @@
import logging
from typing import Any, Optional
from croniter import croniter
from croniter import croniter, CroniterBadDateError
from flask import current_app as app
from flask_babel import gettext as _
from marshmallow import ValidationError
@@ -29,6 +29,7 @@ from superset.commands.report.exceptions import (
ChartNotSavedValidationError,
DashboardNotFoundValidationError,
DashboardNotSavedValidationError,
ReportScheduleCrontabNotValidError,
ReportScheduleEitherChartOrDashboardError,
ReportScheduleForbiddenError,
ReportScheduleFrequencyNotAllowed,
@@ -288,13 +289,18 @@ class BaseReportScheduleCommand(BaseCommand):
return
iterations = 60 if minimum_interval <= 3660 else 24
schedule = croniter(cron_schedule)
current_exec = next(schedule)
try:
schedule = croniter(cron_schedule)
current_exec = next(schedule)
for _i in range(iterations):
next_exec = next(schedule)
diff, current_exec = next_exec - current_exec, next_exec
if int(diff) < minimum_interval:
raise ReportScheduleFrequencyNotAllowed(
report_type=report_type, minimum_interval=minimum_interval
)
for _i in range(iterations):
next_exec = next(schedule)
diff, current_exec = next_exec - current_exec, next_exec
if int(diff) < minimum_interval:
raise ReportScheduleFrequencyNotAllowed(
report_type=report_type, minimum_interval=minimum_interval
)
except CroniterBadDateError as ex:
raise ReportScheduleCrontabNotValidError(
cron_schedule=cron_schedule
) from ex

View File

@@ -133,6 +133,23 @@ class ReportScheduleFrequencyNotAllowed(ValidationError): # noqa: N818
)
class ReportScheduleCrontabNotValidError(ValidationError): # noqa: N818
"""
Marshmallow validation error for a crontab that is syntactically valid
but never matches a real calendar date (e.g. February 30th)
"""
def __init__(self, cron_schedule: str = "") -> None:
super().__init__(
_(
"Invalid crontab schedule: %(cron_schedule)s never matches"
" a valid date",
cron_schedule=cron_schedule,
),
field_name="crontab",
)
class ChartNotSavedValidationError(ValidationError):
"""
Marshmallow validation error for charts that haven't been saved yet

View File

@@ -235,6 +235,15 @@ def _get_drill_detail(
# todo(yongjie): Remove this function,
# when determining whether samples should be applied to the time filter.
datasource = _get_datasource(query_context, query_obj)
# Refuse for datasource types that don't model raw rows (e.g. semantic
# views). Mirrors the ``supports_samples`` gate on the ``/samples``
# endpoint so drill-detail is hard-blocked on the backend, not just
# hidden in the frontend menu. Defaults to ``True`` for any datasource
# class that doesn't explicitly opt out.
if not getattr(datasource, "supports_drill_to_detail", True):
raise QueryObjectValidationError(
_("Drill to detail is not available for this datasource type.")
)
query_obj = copy.copy(query_obj)
query_obj.is_timeseries = False
query_obj.metrics = None

View File

@@ -193,6 +193,16 @@ class BaseDatasource(
# Only some datasources support Row Level Security
is_rls_supported: bool = False
# Datasources that can return raw row samples (anything backed by a SQL
# table can; semantic-layer abstractions cannot, since they only expose
# pre-defined metrics and dimensions).
supports_samples: bool = True
# Datasources that can answer "drill to detail" requests — i.e. fetch the
# raw rows underlying a chart cell. Conceptually similar to ``samples``
# but kept as a separate capability so the two can diverge.
supports_drill_to_detail: bool = True
@property
def name(self) -> str:
# can be a Column or a property pointing to one
@@ -486,6 +496,8 @@ class BaseDatasource(
"order_by_choices": self.order_by_choices,
"verbose_map": self.verbose_map,
"select_star": self.select_star,
"supports_samples": self.supports_samples,
"supports_drill_to_detail": self.supports_drill_to_detail,
}
def data_for_slices( # pylint: disable=too-many-locals # noqa: C901

View File

@@ -113,6 +113,9 @@ class PinotEngineSpec(BaseEngineSpec):
) -> str:
# Pinot driver infers TIMESTAMP column as LONG, so make the quick fix.
# When the Pinot driver fix this bug, current method could be removed.
#
# TODO: remove this override once startreedata/pinot-dbapi#224 is
# merged and released, and pinotdb is bumped past that version.
if isinstance(sqla_column_type, types.TIMESTAMP):
return sqla_column_type.compile().upper()

View File

@@ -200,6 +200,12 @@ class SemanticView(AuditMixinNullable, Model):
__tablename__ = "semantic_views"
# Semantic views expose pre-defined metrics and dimensions, not raw rows,
# so neither the "Samples" tab in Explore nor the "Drill to detail"
# affordance from the chart 3-dots menu can return anything meaningful.
supports_samples: bool = False
supports_drill_to_detail: bool = False
# Use integer as the primary key for cross-database auto-increment
# compatibility (sa.Identity() is not supported in MySQL or SQLite).
# The uuid column is a secondary unique identifier used in URLs and perms.
@@ -425,6 +431,8 @@ class SemanticView(AuditMixinNullable, Model):
"sql": None,
"select_star": None,
"editors": [],
"supports_samples": self.supports_samples,
"supports_drill_to_detail": self.supports_drill_to_detail,
"description": self.description,
"table_name": self.name,
"column_types": [

View File

@@ -346,6 +346,11 @@ class ExplorableData(TypedDict, total=False):
always_filter_main_dttm: bool
normalize_columns: bool
rls_filters: list[dict[str, Any]]
# Set by datasources that cannot return raw row samples (e.g. semantic
# views, which only expose pre-defined metrics and dimensions).
supports_samples: bool
# Set by datasources that cannot answer drill-to-detail requests.
supports_drill_to_detail: bool
VizData: TypeAlias = list[Any] | dict[Any, Any] | None

View File

@@ -223,7 +223,7 @@ msgstr "% du total"
#, python-format
msgid "%(alertType)s \"%(alertName)s\" triggered successfully"
msgstr ""
msgstr "%(alertType)s « %(alertName)s » déclenché avec succès"
#, python-format
msgid "%(dialect)s cannot be used as a data source for security reasons."
@@ -1390,10 +1390,10 @@ msgstr ""
" ou négative par rapport à la valeur de comparaison."
msgid "Adhoc metric SQL expression is invalid"
msgstr ""
msgstr "L'expression SQL de la mesure ad hoc est invalide"
msgid "Adhoc metric aggregate is invalid"
msgstr ""
msgstr "L'agrégat de la mesure ad hoc est invalide"
msgid "Adjust how this database will interact with SQL Lab."
msgstr "Ajuster la façon dont cette base de données interagira avec SQL Lab."
@@ -2018,6 +2018,8 @@ msgid ""
"Angle at which the first slice begins, in degrees. 90° starts at the top,"
" 0°/360° at the right, 270° at the bottom, and 180° at the left."
msgstr ""
"Angle auquel commence la première part, en degrés. 90° démarre en haut, "
"0°/360° à droite, 270° en bas et 180° à gauche."
msgid "Angle at which to end progress axis"
msgstr "Angle de fin de l'axe de progression"
@@ -2278,7 +2280,7 @@ msgid "Are you sure you want to delete the selected layers?"
msgstr "Voulez-vous vraiment supprimer les couches sélectionnées?"
msgid "Are you sure you want to delete the selected queries?"
msgstr ""
msgstr "Voulez-vous vraiment supprimer les requêtes sélectionnées ?"
msgid "Are you sure you want to delete the selected roles?"
msgstr "Voulez-vous vraiment supprimer les rôles sélectionnés ?"
@@ -3527,7 +3529,7 @@ msgid "Clear local theme"
msgstr "Supprimer le thème local"
msgid "Clear search"
msgstr ""
msgstr "Effacer la recherche"
msgid "Clear the selection to revert to the system default theme"
msgstr "Effacer la sélection pour revenir au thème par défaut du système"
@@ -4056,7 +4058,7 @@ msgid "Connection failed, please check your connection settings."
msgstr "La connexion a échoué, veuillez vérifier vos paramètres de connexion"
msgid "Connection looks good!"
msgstr ""
msgstr "La connexion fonctionne !"
msgid "Contains"
msgstr "Contient"
@@ -4144,7 +4146,7 @@ msgid "Copy query"
msgstr "Copier la requête"
msgid "Copy query URL"
msgstr ""
msgstr "Copier l'URL de la requête"
msgid "Copy query link to your clipboard"
msgstr "Copier le lien de la requête vers le presse-papier"
@@ -4274,6 +4276,8 @@ msgid ""
"Create a new tag and assign it to existing entities like charts or "
"dashboards"
msgstr ""
"Créer une balise et l'affecter à des entités existantes comme des "
"graphiques ou des tableaux de bord"
msgid "Create and explore dataset"
msgstr "Créer et explorer un jeu de données"
@@ -4302,7 +4306,7 @@ msgstr "Créé par"
msgid "Created by me"
msgstr "Créé par moi"
, python-format
#, python-format
msgid "Created by: %s"
msgstr "Créé par : %s"
@@ -4597,6 +4601,9 @@ msgid ""
"Dashboard cannot be restored because its slug is now used by another "
"active dashboard. Rename one of the dashboards and retry."
msgstr ""
"Le tableau de bord ne peut pas être restauré car son slug est désormais "
"utilisé par un autre tableau de bord actif. Renommez l'un des deux "
"tableaux de bord et réessayez."
msgid "Dashboard cannot be unfavorited."
msgstr "Le tableau de bord n'a pas pu être retiré des favoris."
@@ -5283,7 +5290,7 @@ msgid "Delete item"
msgstr "Supprimer l'élément"
msgid "Delete query"
msgstr ""
msgstr "Supprimer la requête"
msgid "Delete role"
msgstr "Supprimer le rôle"
@@ -5595,6 +5602,9 @@ msgid ""
"Display charts on a map. For using this plugin, users first have to "
"create any other chart that can then be placed on the map."
msgstr ""
"Affiche des graphiques sur une carte. Pour utiliser ce module, il faut "
"d'abord créer un autre graphique, qui pourra ensuite être placé sur la "
"carte."
msgid "Display column in the chart"
msgstr "Afficher la colonne dans le graphique"
@@ -5990,7 +6000,7 @@ msgstr "ERREUR"
#, python-format
msgid "ERROR: %s"
msgstr ""
msgstr "ERREUR : %s"
msgid "Edge length"
msgstr "Longueur du bord"
@@ -6082,7 +6092,7 @@ msgid "Edit properties"
msgstr "Modifier les propriétés"
msgid "Edit query"
msgstr ""
msgstr "Modifier la requête"
msgid "Edit report"
msgstr "Modifier le rapport"
@@ -6172,7 +6182,7 @@ msgid "Email link"
msgstr "Lien par courriel"
msgid "Email recipients"
msgstr ""
msgstr "Destinataires du courriel"
msgid "Email reports active"
msgstr "Rapports par courriel actifs"
@@ -6425,7 +6435,7 @@ msgid "Entity"
msgstr "Entité"
msgid "Entries per page"
msgstr ""
msgstr "Entrées par page"
msgid "Equal Date Sizes"
msgstr "Taille des dates égales"
@@ -6693,7 +6703,7 @@ msgid "Export as Example"
msgstr "Exporter comme exemple"
msgid "Export as PDF"
msgstr ""
msgstr "Exporter en PDF"
msgid "Export cancelled"
msgstr "Export annulé"
@@ -6712,7 +6722,7 @@ msgid "Export failed: %s"
msgstr "Export échoué : %s"
msgid "Export query"
msgstr ""
msgstr "Exporter la requête"
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: cs, de, ja,
# lv, ro, ru, sk, sr, sr_Latn, tr, uk]
@@ -6720,7 +6730,7 @@ msgid "Export screenshot (jpeg)"
msgstr "Exporter la capture d'écran (jpeg)"
msgid "Export screenshot (png)"
msgstr ""
msgstr "Exporter la capture d'écran (png)"
#, python-format
msgid "Export successful: %s"
@@ -6764,11 +6774,15 @@ msgid ""
"Exporting semantic views is not supported yet — %s semantic-view row(s) "
"were skipped."
msgstr ""
"L'export des vues sémantiques n'est pas encore pris en charge — %s "
"ligne(s) de vue sémantique ont été ignorées."
msgid ""
"Exporting semantic views is not supported yet. Deselect the semantic-view"
" rows and try again."
msgstr ""
"L'export des vues sémantiques n'est pas encore pris en charge. "
"Désélectionnez les lignes de vue sémantique et réessayez."
msgid "Expose database in SQL Lab"
msgstr "Exposer la base de données dans SQL Lab"
@@ -6881,6 +6895,8 @@ msgid ""
"Failed to export chart data. Please try again or contact your "
"administrator."
msgstr ""
"Échec de l'export des données du graphique. Réessayez ou contactez votre "
"administrateur."
msgid "Failed to fetch API keys"
msgstr "Tout Dé-Sélectionner"
@@ -6956,7 +6972,7 @@ msgstr "Échec du marquage des éléments"
#, python-format
msgid "Failed to trigger %(alertType)s \"%(alertName)s\": %(error)s"
msgstr ""
msgstr "Échec du déclenchement de %(alertType)s « %(alertName)s » : %(error)s"
msgid "Failed to update report"
msgstr "Échec de la mise à jour du rapport"
@@ -7315,7 +7331,7 @@ msgid "Forecast periods"
msgstr "Périodes de prévision"
msgid "Forecast requires at least 2 data points"
msgstr ""
msgstr "La prévision nécessite au moins 2 points de données"
msgid "Foreign key"
msgstr "Clé étrangère"
@@ -7371,10 +7387,10 @@ msgstr ""
" sont présentes, le formatage revient aux nombres neutres."
msgid "Formatted CSV attached in email"
msgstr "CSV formatté attaché dans le courriel"
msgstr "Fichier CSV mis en forme joint au courriel"
msgid "Formatted Excel attached in email"
msgstr ""
msgstr "Fichier Excel mis en forme joint au courriel"
msgid "Formatted date"
msgstr "Date formatée"
@@ -8318,7 +8334,7 @@ msgid "Label for your query"
msgstr "Label pour votre requête"
msgid "Label must not be empty."
msgstr ""
msgstr "L'étiquette ne doit pas être vide."
msgid "Label position"
msgstr "Position de l'étiquette"
@@ -8619,7 +8635,7 @@ msgid "Lines encoding"
msgstr "Codage des lignes"
msgid "Link Copied!"
msgstr ""
msgstr "Lien copié !"
msgid "List"
msgstr "Liste"
@@ -9556,7 +9572,7 @@ msgstr ""
"enregistrement temporel"
msgid "No data found"
msgstr ""
msgstr "Aucune donnée trouvée"
msgid "No data in file"
msgstr "Pas de données dans le fichier"
@@ -9972,7 +9988,7 @@ msgstr "Une ou plusieurs mesures n'existent pas"
#, python-format
msgid "One or more parameters are missing: %(missing)s"
msgstr ""
msgstr "Un ou plusieurs paramètres sont manquants : %(missing)s"
msgid "One or more parameters needed to configure a database are missing."
msgstr ""
@@ -10675,6 +10691,11 @@ msgid ""
"period (e.g. today so far) against complete prior periods (e.g. all of "
"yesterday)."
msgstr ""
"Tracer chaque série décalée dans le temps sur toute sa plage temporelle "
"au lieu de la tronquer à celle de la série principale. Utile pour "
"comparer une période en cours partielle (par exemple aujourd'hui jusqu'à "
"maintenant) à des périodes antérieures complètes (par exemple la journée "
"d'hier entière)."
msgid "Plot the distance (like flight paths) between origin and destination."
msgstr ""
@@ -11135,6 +11156,12 @@ msgid ""
"except the subjects defined in the filter, and can be used to define what"
" users can see if no RLS filters within a filter group apply to them."
msgstr ""
"Les filtres classiques ajoutent des clauses WHERE aux requêtes lorsqu'un "
"utilisateur correspond à un sujet référencé par le filtre. Les filtres de"
" base appliquent des filtres à toutes les requêtes sauf pour les sujets "
"définis dans le filtre ; ils permettent de définir ce que voient les "
"utilisateurs auxquels aucun filtre RLS d'un groupe de filtres ne "
"s'applique."
msgid "Relational"
msgstr "Relationnel"
@@ -11175,7 +11202,7 @@ msgid "Remove customization"
msgstr "Supprimer la personnalisation"
msgid "Remove dependency"
msgstr ""
msgstr "Supprimer la dépendance"
msgid "Remove filter"
msgstr "Supprimer le filtre"
@@ -11184,13 +11211,13 @@ msgid "Remove item"
msgstr "Supprimer lélément"
msgid "Remove notification method"
msgstr ""
msgstr "Supprimer le mode de notification"
msgid "Remove query from log"
msgstr "Supprimer la requête des journaux"
msgid "Remove sheet"
msgstr ""
msgstr "Supprimer la feuille"
#, python-format
msgid "Removed 1 column from the virtual dataset"
@@ -11234,7 +11261,7 @@ msgid "Report Schedule delete failed."
msgstr "La planification de rapport n'a pas être supprimée."
msgid "Report Schedule execute now failed."
msgstr ""
msgstr "L'exécution immédiate de la planification de rapport a échoué."
msgid "Report Schedule execution failed when generating a csv."
msgstr ""
@@ -11258,6 +11285,8 @@ msgstr ""
msgid "Report Schedule execution failed when generating an Excel file."
msgstr ""
"L'exécution de la planification de rapport a échoué lors de la génération"
" du fichier Excel."
msgid "Report Schedule execution got an unexpected error."
msgstr ""
@@ -11269,10 +11298,15 @@ msgid ""
"Please configure a Celery broker (Redis or RabbitMQ) and worker "
"processes."
msgstr ""
"L'exécution de la planification de rapport nécessite un backend Celery "
"configuré. Configurez un broker Celery (Redis ou RabbitMQ) et des "
"processus worker."
#, python-format
msgid "Report Schedule executor user %(username)s was not found."
msgstr ""
"L'utilisateur %(username)s exécutant la planification de rapport est "
"introuvable."
msgid "Report Schedule is still working, refusing to re-compute."
msgstr ""
@@ -11981,7 +12015,7 @@ msgid "Search Metrics & Columns"
msgstr "Rechercher les mesures et les colonnes"
msgid "Search a channel by name, or paste a channel ID"
msgstr ""
msgstr "Rechercher un canal par son nom, ou coller un identifiant de canal"
msgid "Search all charts"
msgstr "Rechercher tous les graphiques"
@@ -12026,7 +12060,7 @@ msgid "Search owners"
msgstr "Rechercher des propriétaires"
msgid "Search records"
msgstr ""
msgstr "Rechercher des enregistrements"
msgid "Search roles"
msgstr "Recherche de rôles"
@@ -12042,6 +12076,8 @@ msgstr "Rechercher…"
msgid "Searches all text fields: Name, Description, Database & Schema"
msgstr ""
"Recherche dans tous les champs texte : nom, description, base de données "
"et schéma"
msgid "Second"
msgstr "Seconde"
@@ -12084,7 +12120,7 @@ msgid "See all %(tableName)s"
msgstr "Voir tout %(tableName)s"
msgid "See all dashboards"
msgstr ""
msgstr "Voir tous les tableaux de bord"
msgid "See less"
msgstr "Voir moins"
@@ -12375,10 +12411,10 @@ msgid "Select operator"
msgstr "Sélectionner l'opérateur"
msgid "Select or type BCC recipients"
msgstr ""
msgstr "Sélectionner ou saisir les destinataires en Cci"
msgid "Select or type CC recipients"
msgstr ""
msgstr "Sélectionner ou saisir les destinataires en Cc"
msgid "Select or type a custom value..."
msgstr "Sélectionner ou renseigner une valeur personnalisé..."
@@ -12390,7 +12426,7 @@ msgid "Select or type dataset name"
msgstr "Sélectionner la base de données ou taper le nom du jeu de données"
msgid "Select or type email recipients"
msgstr ""
msgstr "Sélectionner ou saisir les destinataires du courriel"
msgid "Select page size"
msgstr "Sélectionner la taille de la page"
@@ -12639,7 +12675,7 @@ msgid "Send as CSV"
msgstr "Envoyer comme CSV"
msgid "Send as Excel"
msgstr ""
msgstr "Envoyer comme Excel"
msgid "Send as PDF"
msgstr "Envoyer comme PDF"
@@ -12871,7 +12907,7 @@ msgid "Show Metric Names"
msgstr "Afficher les noms de mesure"
msgid "Show Null Values"
msgstr ""
msgstr "Afficher les valeurs nulles"
msgid "Show Range Filter"
msgstr "Afficher l'intervalle de filtre"
@@ -12912,13 +12948,17 @@ msgstr ""
"autrement min/max dans les données."
msgid "Show a draggable slider to control the visible range of the Y-axis."
msgstr ""
msgstr "Afficher un curseur déplaçable pour contrôler la plage visible de l'axe Y."
msgid ""
"Show a summary row of total aggregations: the selected metrics in "
"aggregate mode, or the sum of numeric columns in raw records mode. Note "
"that row limit does not apply to the result."
msgstr ""
"Afficher une ligne de synthèse des agrégats totaux : les mesures "
"sélectionnées en mode agrégé, ou la somme des colonnes numériques en mode"
" enregistrements bruts. Notez que la limite de lignes ne s'applique pas "
"au résultat."
msgid "Show all columns"
msgstr "Afficher toutes les colonnes"
@@ -12959,7 +12999,7 @@ msgid "Show entries per page"
msgstr "Afficher le nombre d'éléments par page"
msgid "Show full range for time shift"
msgstr ""
msgstr "Afficher la plage complète pour le décalage temporel"
msgid ""
"Show hierarchical relationships of data, with the value represented by "
@@ -13046,6 +13086,8 @@ msgid ""
"Showcases a metric along with a comparison of value, change, and percent "
"change for a selected time period."
msgstr ""
"Met en avant une mesure avec une comparaison de la valeur, de l'écart et "
"de l'écart en pourcentage sur une période sélectionnée."
msgid ""
"Showcases a single metric front-and-center. Big number is best used to "
@@ -13189,7 +13231,7 @@ msgid "Solid"
msgstr "Solide"
msgid "Solid background"
msgstr ""
msgstr "Fond uni"
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
@@ -13213,12 +13255,14 @@ msgstr ""
"seront pas effacés"
msgid "Some tables are not shown. Refine your search."
msgstr ""
msgstr "Certaines tables ne sont pas affichées. Affinez votre recherche."
msgid ""
"Something went wrong loading the dashboard. Check the dev console for "
"details."
msgstr ""
"Un problème est survenu lors du chargement du tableau de bord. Consultez "
"la console développeur pour plus de détails."
msgid "Something went wrong while saving the user info"
msgstr "Une erreur s'est produite. Réessayez plus tard."
@@ -13628,7 +13672,7 @@ msgid "Success"
msgstr "Réussite"
msgid "Success message"
msgstr ""
msgstr "Message de succès"
#, python-format
msgid "Successfully changed %s!"
@@ -13702,7 +13746,7 @@ msgid "Swap rows and columns"
msgstr "Échanger les rangées et les colonnes"
msgid "Sweep angle"
msgstr ""
msgstr "Angle de balayage"
msgid ""
"Swiss army knife for visualizing data. Choose between step, line, "
@@ -13870,7 +13914,7 @@ msgid "Tag created"
msgstr "Balise créée"
msgid "Tag description"
msgstr ""
msgstr "Description de la balise"
msgid "Tag name"
msgstr "Nom de la balise"
@@ -14000,11 +14044,13 @@ msgid "Text align"
msgstr "Alignement du texte"
msgid "Text embedded in email"
msgstr "Text encapsulé dans le courriel"
msgstr "Texte encapsulé dans le courriel"
#, python-format
msgid "The %(key)s in metadata_cache_timeout must be a non-negative integer."
msgstr ""
"La valeur de %(key)s dans metadata_cache_timeout doit être un entier "
"positif ou nul."
#, python-format
msgid "The %s"
@@ -14124,6 +14170,9 @@ msgid ""
"The chart data is too large to download. Please try reducing the date "
"range, limiting rows, or using fewer columns."
msgstr ""
"Les données du graphique sont trop volumineuses pour être téléchargées. "
"Réduisez la plage de dates, limitez le nombre de lignes ou utilisez moins"
" de colonnes."
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
@@ -14141,7 +14190,7 @@ msgstr ""
" ou mettez à jour le rapport pour pointer vers un graphique actif."
msgid "The chat failed to load."
msgstr ""
msgstr "La conversation n'a pas pu être chargée."
msgid ""
"The classic. Great for showing how much of a company each investor gets, "
@@ -14239,6 +14288,8 @@ msgstr ""
msgid "The dashboard you are looking for may have been deleted or moved."
msgstr ""
"Le tableau de bord que vous recherchez a peut-être été supprimé ou "
"déplacé."
msgid "The data source seems to have been deleted"
msgstr "La source de données semble avoir été effacée"
@@ -14523,6 +14574,8 @@ msgid ""
"The metadata_cache_timeout must be a mapping from string keys to non-"
"negative integer values."
msgstr ""
"metadata_cache_timeout doit être une correspondance entre des clés de "
"type chaîne et des entiers positifs ou nuls."
#, python-format
msgid ""
@@ -14759,6 +14812,8 @@ msgstr "Cette requête contient un ou plusieurs paramètres de modèle malformé
msgid "The query context datasource does not match the chart datasource"
msgstr ""
"La source de données du contexte de requête ne correspond pas à celle du "
"graphique"
msgid "The query couldn't be loaded"
msgstr "La requête ne peut pas être chargée"
@@ -15200,7 +15255,7 @@ msgid "There was an error fetching the filtered charts and dashboards:"
msgstr "Une erreur sest produite lors de la récupération des graphiques et tableaux de bord filtrés :"
msgid "There was an error generating the permalink."
msgstr ""
msgstr "Une erreur s'est produite lors de la génération du lien permanent."
msgid "There was an error loading groups."
msgstr "Une erreur s'est produite lors du chargement des groupes."
@@ -15305,6 +15360,8 @@ msgstr ""
#, python-format
msgid "There was an issue deleting the selected queries: %s"
msgstr ""
"Un problème est survenu lors de la suppression des requêtes sélectionnées"
" : %s"
#, python-format
msgid "There was an issue deleting the selected templates: %s"
@@ -15351,7 +15408,7 @@ msgid "There was an issue exporting the selected dashboards"
msgstr "Il y a eu un problème lors de l'export des tableaux de bord sélectionnés"
msgid "There was an issue exporting the selected queries"
msgstr ""
msgstr "Un problème est survenu lors de l'export des requêtes sélectionnées"
msgid "There was an issue exporting the selected themes"
msgstr "Il y a eu un problème lors de l'export des thèmes sélectionnés"
@@ -15518,7 +15575,7 @@ msgstr ""
"être transmis au graphique contenant les données d'annotation."
msgid "This dashboard does not exist"
msgstr ""
msgstr "Ce tableau de bord n'existe pas"
msgid "This dashboard is managed externally, and can't be edited in Superset"
msgstr ""
@@ -15906,6 +15963,8 @@ msgstr "Fragment de temps"
msgid "Time Grain must be specified when using Time Comparison."
msgstr ""
"Le fragment de temps doit être précisé lors de l'utilisation de la "
"comparaison de temps."
msgid "Time Granularity"
msgstr "Fragmentation de Temps"
@@ -16195,6 +16254,10 @@ msgid ""
" angle is a multiple of 90°, the chart is automatically re-centered to "
"make use of the empty space."
msgstr ""
"Angle total couvert par le graphique, en degrés. 360° dessine un cercle "
"complet et 180° un demi-anneau. Lorsque le balayage est inférieur ou égal"
" à 180° et que l'angle de départ est un multiple de 90°, le graphique est"
" automatiquement recentré pour exploiter l'espace vide."
msgid "Total color"
msgstr "Couleur du total"
@@ -16219,7 +16282,7 @@ msgid "Transparent"
msgstr "Transparent"
msgid "Transparent background"
msgstr ""
msgstr "Fond transparent"
msgid "Transpose pivot"
msgstr "Pivot de transposition"
@@ -16249,7 +16312,7 @@ msgid "Trigger Alert If..."
msgstr "Déclencher une alerte si…"
msgid "Trigger now"
msgstr ""
msgstr "Déclencher maintenant"
msgid "True"
msgstr "Est vrai"
@@ -16368,7 +16431,7 @@ msgid "URL parameters"
msgstr "Paramètres URL"
msgid "UUID to track the execution status"
msgstr ""
msgstr "UUID permettant de suivre l'état de l'exécution"
msgid "Unable to calculate such a date delta"
msgstr "Impossible de calculer un delta de date comme celui-ci"
@@ -16435,7 +16498,7 @@ msgstr "Impossible de générer les données de téléchargement"
#, python-format
msgid "Unable to generate forecast: %(error)s"
msgstr ""
msgstr "Impossible de générer la prévision : %(error)s"
msgid ""
"Unable to identify temporal column for date range time comparison.Please "
@@ -16450,6 +16513,8 @@ msgid ""
"Unable to interpret the time offset: %(offset)s. Use a relative time such"
" as \"1 month ago\"."
msgstr ""
"Impossible d'interpréter le décalage temporel : %(offset)s. Utilisez une "
"expression relative telle que « 1 month ago »."
msgid ""
"Unable to load columns for the selected table. Please select a different "
@@ -17497,6 +17562,8 @@ msgstr "Affichage ou non des bulles au-dessus des pays"
msgid "Whether to display entries with null values in the hierarchy"
msgstr ""
"Afficher ou non les entrées dont les valeurs sont nulles dans la "
"hiérarchie"
msgid "Whether to display in the chart"
msgstr "Afficher ou non dans le graphique"
@@ -17632,6 +17699,9 @@ msgid ""
"Whether to sort tooltip by the selected metric in descending order. On "
"stacked charts, values are shown in ascending order."
msgstr ""
"Trier ou non l'infobulle par la mesure sélectionnée dans l'ordre "
"décroissant. Sur les graphiques empilés, les valeurs sont affichées dans "
"l'ordre croissant."
msgid "Whether to truncate metrics"
msgstr "Tronquer ou non les mesures"
@@ -17791,7 +17861,7 @@ msgid "Y-axis bounds"
msgstr "Limites de laxe des ordonnées"
msgid "Y-axis range slider"
msgstr ""
msgstr "Curseur de plage de l'axe Y"
msgid "Y-scale interval"
msgstr "Intervalle d'échelle Y"
@@ -18066,26 +18136,41 @@ msgid ""
"You must be a chart editor in order to delete. Please reach out to a "
"chart editor to request modifications or edit access."
msgstr ""
"Vous devez être éditeur du graphique pour pouvoir supprimer. Contactez un"
" éditeur du graphique pour demander des modifications ou un accès en "
"modification."
msgid ""
"You must be a chart editor in order to edit. Please reach out to a chart "
"editor to request modifications or edit access."
msgstr ""
"Vous devez être éditeur du graphique pour pouvoir modifier. Contactez un "
"éditeur du graphique pour demander des modifications ou un accès en "
"modification."
msgid ""
"You must be a dashboard editor in order to delete. Please reach out to a "
"dashboard editor to request modifications or edit access."
msgstr ""
"Vous devez être éditeur du tableau de bord pour pouvoir supprimer. "
"Contactez un éditeur du tableau de bord pour demander des modifications "
"ou un accès en modification."
msgid ""
"You must be a dashboard editor in order to edit. Please reach out to a "
"dashboard editor to request modifications or edit access."
msgstr ""
"Vous devez être éditeur du tableau de bord pour pouvoir modifier. "
"Contactez un éditeur du tableau de bord pour demander des modifications "
"ou un accès en modification."
msgid ""
"You must be a dataset editor in order to delete. Please reach out to a "
"dataset editor to request modifications or edit access."
msgstr ""
"Vous devez être éditeur de l'ensemble de données pour pouvoir supprimer. "
"Contactez un éditeur de l'ensemble de données pour demander des "
"modifications ou un accès en modification."
msgid ""
"You must be a dataset editor in order to edit. Please reach out to a "
@@ -18172,6 +18257,11 @@ msgid ""
"into multiple dashboards) or raise the "
"SUPERSET_DASHBOARD_POSITION_DATA_LIMIT config setting."
msgstr ""
"Votre tableau de bord est trop volumineux pour être enregistré : la "
"longueur sérialisée de la disposition est de %s alors que la limite est "
"de %s. Réduisez la taille du tableau de bord (par exemple en le scindant "
"en plusieurs tableaux de bord) ou augmentez le paramètre de configuration"
" SUPERSET_DASHBOARD_POSITION_DATA_LIMIT."
msgid "Your query could not be saved"
msgstr "Votre requête n'a pas pu être enregistrée"
@@ -19026,7 +19116,7 @@ msgid "quarter"
msgstr "trimestre"
msgid "queries"
msgstr ""
msgstr "requêtes"
msgid "query"
msgstr "requête"
@@ -19072,7 +19162,7 @@ msgid "seconds"
msgstr "secondes"
msgid "semantic layer"
msgstr ""
msgstr "couche sémantique"
msgid "series"
msgstr "série"

View File

@@ -87,6 +87,29 @@ def resolve_screenshot_task_budget_seconds(
return None
# Fallback wall-clock budget, in seconds, for the entire tiled-screenshot
# operation (element lookup plus all per-tile readiness/animation waits
# combined), used when resolve_screenshot_task_budget_seconds() returns None
# (no Celery task context -- e.g. synchronous thumbnail generation -- or no
# usable task limit). The non-tiled readiness path treats None as "keep the
# configured SCREENSHOT_LOAD_WAIT" because it makes exactly one bounded wait;
# the tiled path cannot, because its per-tile waits accumulate: with N tiles,
# an uncapped load_wait allows N * load_wait of total wall-clock time, so the
# operation still needs one fixed total ceiling. Sized against the longest
# Celery hard task_time_limit observed in production for report execution
# (1740s), minus the same 300s cleanup margin the runtime derivation reserves
# for combining tiles, building the PDF, and delivering the notification.
TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS = 1440 # 1740s limit - 300s margin
class ScreenshotTaskBudgetExceededError(RuntimeError):
"""Raised when no safe task budget remains before screenshot capture."""
class TiledScreenshotBudgetExceededError(ScreenshotTaskBudgetExceededError):
"""Raised when the tiled-screenshot time budget runs out mid-capture."""
try:
from playwright.sync_api import TimeoutError as PlaywrightTimeout
except ImportError:
@@ -251,7 +274,7 @@ def combine_screenshot_tiles(screenshot_tiles: list[bytes]) -> bytes:
return screenshot_tiles[0]
def take_tiled_screenshot(
def take_tiled_screenshot( # noqa: C901
page: "Page",
element_name: str,
tile_height: int,
@@ -274,6 +297,12 @@ def take_tiled_screenshot(
Returns:
Combined screenshot bytes or None if failed
Raises:
TiledScreenshotBudgetExceededError: If the total time budget for the
tiled-screenshot operation runs out before every tile has been
verifiably captured. Callers must treat this as a hard failure
rather than fall back to an unchecked/partial screenshot.
"""
context_suffix = f" [{log_context}]" if log_context else ""
# Set right before re-raising the per-tile readiness timeout below, and
@@ -286,6 +315,15 @@ def take_tiled_screenshot(
# match `except PlaywrightTimeout` and incorrectly propagate instead of
# degrading to `None` like every other unexpected error in this function.
readiness_timeout = False
# Cap the whole tiled operation against the running Celery task's own
# time limit, using the same runtime derivation as the non-tiled
# readiness wait (#42253/#42427). Unlike that path, a None budget does
# not mean "keep the configured timeout": per-tile waits accumulate, so
# the operation falls back to a fixed total ceiling instead.
wait_budget_seconds = resolve_screenshot_task_budget_seconds(log_context)
if wait_budget_seconds is None:
wait_budget_seconds = float(TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS)
start_time = time.monotonic()
try:
# Get the target element
element = page.locator(f".{element_name}")
@@ -320,9 +358,44 @@ def take_tiled_screenshot(
num_tiles = max(1, (dashboard_height + tile_height - 1) // tile_height)
logger.info("Taking %s screenshot tiles", num_tiles)
screenshot_tiles = []
screenshot_tiles: list[bytes] = []
def _raise_if_budget_exhausted(elapsed: float, remaining_budget: float) -> None:
if remaining_budget > 0:
return
# A customer-side chart-loading issue (a slow/hung dashboard),
# not a Superset system fault, so this is a WARNING rather
# than an ERROR -- consistent with #38130/#38441, which
# deliberately downgraded screenshot timeout logs the same way.
logger.warning(
"Tiled screenshot time budget exhausted on tile %s/%s: "
"%s/%s tiles captured so far, %.1fs elapsed of a %.1fs "
"budget. Aborting instead of capturing remaining tiles "
"unchecked.%s",
i + 1,
num_tiles,
len(screenshot_tiles),
num_tiles,
elapsed,
wait_budget_seconds,
context_suffix,
)
raise TiledScreenshotBudgetExceededError(
f"Tiled screenshot budget of "
f"{wait_budget_seconds:.1f}s exhausted "
f"after {len(screenshot_tiles)}/{num_tiles} tiles"
)
for i in range(num_tiles):
# Check the time budget before starting this tile's readiness wait.
# If it's already exhausted, we can no longer verify this (or any
# later) tile is actually ready to capture -- fail loudly instead
# of silently snapshotting a spinner or blank chart, or running
# past the Celery task time limit and getting SIGKILLed.
elapsed = time.monotonic() - start_time
remaining_budget = wait_budget_seconds - elapsed
_raise_if_budget_exhausted(elapsed, remaining_budget)
# Calculate scroll position to show this tile's content
scroll_y = dashboard_top + (i * tile_height)
@@ -332,17 +405,31 @@ def take_tiled_screenshot(
)
# Wait for scroll to settle and content to load
page.wait_for_timeout(SCROLL_SETTLE_TIMEOUT_MS)
# Recompute the remaining budget after the scroll-settle sleep --
# which itself consumes real wall-clock time -- rather than
# reusing the value from before it, so the readiness-check
# timeout below is capped against a fresh number instead of a
# stale one that would let each tile overrun the budget by up
# to one settle interval.
tile_wait_start = time.monotonic()
elapsed = tile_wait_start - start_time
remaining_budget = wait_budget_seconds - elapsed
_raise_if_budget_exhausted(elapsed, remaining_budget)
# Wait for every chart holder visible in the current viewport to reach
# a terminal state (rendered chart or error/empty state). Only check
# a terminal state (rendered chart or error/empty state), capped at
# whatever remains of the total time budget so a slow dashboard
# degrades gracefully instead of exceeding it. Only check
# viewport-visible chart holders to avoid blocking on virtualization
# placeholders rendered for off-screen charts. A holder that hasn't
# mounted anything yet does not satisfy this check -- unlike checking
# for the absence of `.loading`, which passes vacuously in that case.
tile_wait_start = time.monotonic()
tile_load_wait = min(load_wait, remaining_budget)
try:
page.wait_for_function(
CHART_HOLDERS_READY_JS,
timeout=load_wait * 1000,
timeout=tile_load_wait * 1000,
)
except PlaywrightTimeout:
elapsed = time.monotonic() - tile_wait_start
@@ -354,14 +441,21 @@ def take_tiled_screenshot(
# made the same call for the other screenshot timeout paths.
logger.warning(
"Timed out after %.2fs waiting for %s chart container(s) to "
"become ready on tile %s/%s (load_wait=%ss)%s; unready chart "
"holders (chart id, state): %s. Aborting tiled screenshot "
"rather than capturing a blank or partially-loaded tile.",
"become ready on tile %s/%s (waited %.1fs of a %ss requested "
"load_wait; %.1fs elapsed of a %.1fs total budget; %s/%s "
"tiles captured so far)%s; unready chart holders (chart id, "
"state): %s. Aborting tiled screenshot rather than capturing "
"a blank or partially-loaded tile.",
elapsed,
len(unready_chart_holders),
i + 1,
num_tiles,
tile_load_wait,
load_wait,
time.monotonic() - start_time,
wait_budget_seconds,
len(screenshot_tiles),
num_tiles,
context_suffix,
unready_chart_holders,
)
@@ -377,12 +471,36 @@ def take_tiled_screenshot(
load_wait,
context_suffix,
)
readiness_wait_elapsed = time.monotonic() - tile_wait_start
# Wait for chart animations (e.g. ECharts) to finish after spinner clears.
# The global animation wait before tiling only covers the first tile;
# subsequent tiles need their own wait after data loads.
# subsequent tiles need their own wait after data loads. Capped at
# whatever remains of the budget; unlike the readiness wait above this
# is cosmetic settling, not a readiness check, so we simply skip it
# (rather than raise) once the budget runs out.
animation_wait_elapsed = 0.0
if animation_wait > 0:
page.wait_for_timeout(animation_wait * 1000)
elapsed = time.monotonic() - start_time
remaining_budget = wait_budget_seconds - elapsed
tile_animation_wait = max(0, min(animation_wait, remaining_budget))
if tile_animation_wait > 0:
animation_wait_start = time.monotonic()
page.wait_for_timeout(tile_animation_wait * 1000)
animation_wait_elapsed = time.monotonic() - animation_wait_start
# Per-tile timing breakdown so slow dashboards can be profiled from
# logs alone. DEBUG rather than INFO: this fires once per tile, and
# large dashboards can have dozens of tiles per report run.
logger.debug(
"Tile %s/%s timing: %.2fs waiting for chart readiness, "
"%.2fs waiting for animations.%s",
i + 1,
num_tiles,
readiness_wait_elapsed,
animation_wait_elapsed,
context_suffix,
)
# Calculate what portion of the element we want to capture for this tile
tile_start_in_element = i * tile_height
@@ -431,6 +549,12 @@ def take_tiled_screenshot(
return combined_screenshot
except TiledScreenshotBudgetExceededError:
# Budget exhaustion must fail cleanly, not be swallowed into the
# generic `return None` degradation below -- the raise carries the
# budget diagnostics to the caller, which fails the capture loudly
# (#42273) instead of receiving an anonymous empty result.
raise
except Exception as e:
if readiness_timeout:
# Let the per-tile readiness timeout propagate so the caller

View File

@@ -85,6 +85,26 @@ class ScreenshotCachePayloadType(TypedDict):
status: str
# Magic bytes for a cheap image sanity check. This is intentionally not a full
# decode: it's meant to catch 0-byte/corrupt/blank payloads before they're
# cached or served, not to validate the image is renderable.
PNG_MAGIC_BYTES = b"\x89PNG\r\n\x1a\n"
JPEG_MAGIC_BYTES = b"\xff\xd8\xff"
def validate_screenshot_image(image: bytes | None) -> str | None:
"""Cheaply validate screenshot bytes before they're cached or served.
:return: None if the bytes look like a usable image, otherwise a short
reason ("empty" or "undecodable") suitable for logging.
"""
if not image:
return "empty"
if not image.startswith((PNG_MAGIC_BYTES, JPEG_MAGIC_BYTES)):
return "undecodable"
return None
class ScreenshotCachePayload:
def __init__(
self,
@@ -147,6 +167,13 @@ class ScreenshotCachePayload:
def get_status(self) -> str:
return self.status.value
def get_invalid_image_reason(self) -> str | None:
"""Reason this payload's image should not be served/cached, or None if
it passes validation (or it isn't claiming a successful screenshot)."""
if self.status != StatusValues.UPDATED:
return None
return validate_screenshot_image(self._image)
def is_error_cache_ttl_expired(self) -> bool:
error_cache_ttl = app.config["THUMBNAIL_ERROR_CACHE_TTL"]
return (
@@ -263,6 +290,14 @@ class BaseScreenshot:
elif isinstance(payload, dict):
payload = cast(ScreenshotCachePayloadType, payload)
payload = ScreenshotCachePayload.from_dict(payload)
if invalid_reason := payload.get_invalid_image_reason():
logger.warning(
"Rejecting cached screenshot for %s: %s image payload; "
"treating as a cache miss",
cache_key,
invalid_reason,
)
return None
return payload
logger.info("Failed at getting from cache: %s", cache_key)
return None
@@ -331,15 +366,28 @@ class BaseScreenshot:
image = None
# Cache the result (success or error) to avoid immediate retries
if image:
invalid_reason = validate_screenshot_image(image)
# `image and` is redundant at runtime (validate_screenshot_image
# only returns None for truthy, well-formed bytes) but mypy can't
# infer that image is non-None from invalid_reason being None
# across the function-call boundary, so it's kept for narrowing.
if image and invalid_reason is None:
with event_logger.log_context(
f"screenshot.cache.{self.thumbnail_type}"
):
cache_payload.update(image)
elif cache_payload.status != StatusValues.ERROR:
# Only call error() if not already set — avoids overwriting
# the timestamp recorded when the actual failure occurred above.
cache_payload.error()
else:
if invalid_reason:
logger.warning(
"Not caching screenshot result for %s: %s image payload",
cache_key,
invalid_reason,
)
if cache_payload.status != StatusValues.ERROR:
# Only call error() if not already set — avoids overwriting
# the timestamp recorded when the actual failure occurred
# above.
cache_payload.error()
logger.info("Caching thumbnail: %s", cache_key)
self.cache.set(cache_key, cache_payload.to_dict())

View File

@@ -47,6 +47,7 @@ from superset.utils.screenshot_utils import (
CHART_HOLDERS_READY_JS,
FIND_CHART_HOLDER_STATES_JS,
resolve_screenshot_task_budget_seconds,
ScreenshotTaskBudgetExceededError,
take_tiled_screenshot,
)
@@ -61,10 +62,6 @@ PLAYWRIGHT_INSTALL_MESSAGE = (
)
class ScreenshotTaskBudgetExceededError(RuntimeError):
"""Raised when no safe task budget remains before screenshot capture."""
if TYPE_CHECKING:
from typing import Any
@@ -482,16 +479,31 @@ class WebDriverPlaywright(WebDriverProxy):
logger.exception("Timed out requesting url %s", url)
raise
slice_container_elems: list[Locator] = []
rendered_chart_count = 0
try:
# chart containers didn't render
logger.debug("Wait for chart containers to draw at url: %s", url)
slice_container_locator = page.locator(".chart-container")
for slice_container_elem in slice_container_locator.all():
# One-time snapshot: containers mounting after this point
# are neither waited on nor counted, so the progress
# numbers below describe the snapshot, not the final DOM.
slice_container_elems = slice_container_locator.all()
for slice_container_elem in slice_container_elems:
slice_container_elem.wait_for()
rendered_chart_count += 1
except PlaywrightTimeout:
logger.exception(
"Timed out waiting for chart containers to draw at url %s",
# Customer-side chart loading is often just slow, not a
# Superset bug, so this is a WARNING (matching the other
# locate-wait timeouts below) rather than an ERROR -- but
# it still fails the screenshot; see the `raise` below.
logger.warning(
"Timed out waiting for chart containers to draw at url %s "
"(%s of %s chart containers rendered before the timeout)",
url,
rendered_chart_count,
len(slice_container_elems),
exc_info=True,
)
raise
selenium_animation_wait = app.config[
@@ -529,19 +541,38 @@ class WebDriverPlaywright(WebDriverProxy):
"SCREENSHOT_TILED_VIEWPORT_HEIGHT", viewport_height
)
if dashboard_height == 0:
logger.warning(
# A height of 0 means the DOM query above found no matching
# element (or it hadn't laid out yet), not that the
# dashboard is actually empty. Treat it as "unknown" rather
# than "fits in a single tile": chart_count alone already
# tells us whether this looks like a large dashboard, and
# that signal must not be silently vetoed just because we
# couldn't measure height, or a large dashboard could skip
# tiling and ship with unrendered below-the-fold charts.
height_unknown = dashboard_height == 0
likely_large_dashboard = (
chart_count >= chart_threshold
or dashboard_height > height_threshold
)
if height_unknown:
log_fn = (
logger.warning if likely_large_dashboard else logger.debug
)
log_fn(
"Could not determine dashboard height for element %s "
"at url %s; falling back to standard screenshot behavior",
"at url %s (%s chart containers found); %s",
element_name,
url,
chart_count,
"attempting tiled screenshot anyway"
if likely_large_dashboard
else "falling back to standard screenshot behavior",
)
# Use tiled screenshots for large dashboards
use_tiled = (
chart_count >= chart_threshold
or dashboard_height > height_threshold
) and dashboard_height > tile_height
use_tiled = likely_large_dashboard and (
height_unknown or dashboard_height > tile_height
)
if use_tiled:
logger.info(

View File

@@ -202,6 +202,23 @@ class Datasource(BaseSupersetView):
payload = SamplesPayloadSchema().load(request.json)
except ValidationError as err:
return json_error_response(err.messages, status=400)
# Refuse early for datasource types that don't model raw rows
# (e.g. semantic views, which only expose pre-defined metrics and
# dimensions). Without this gate the request would still go through
# the standard query pipeline and fail with an opaque 500.
# ``supports_samples`` defaults to True for any datasource class that
# doesn't explicitly opt out, so SqlaTable/Query/SavedQuery continue
# to work without needing the attribute declared on each class.
ds_class = DatasourceDAO.sources.get(
DatasourceType(params["datasource_type"]),
)
if ds_class is not None and not getattr(ds_class, "supports_samples", True):
return json_error_response(
_("Samples are not available for this datasource type."),
status=400,
)
dashboard_id = None
if security_manager.is_guest_user():
if not params["dashboard_id"]:

View File

@@ -224,6 +224,7 @@ class TestChartRestoreApi(SupersetTestCase):
chart_id = chart.id
chart_uuid = str(chart.uuid)
entity_uuid = chart.uuid
assert entity_uuid is not None
original_name = chart.slice_name
original_created_by = chart.created_by_fk
before_changed_on = chart.changed_on
@@ -329,6 +330,8 @@ class TestChartRestoreApi(SupersetTestCase):
assert alpha not in chart.editors
ver_cls = version_class(Slice)
entity_uuid = chart.uuid
assert entity_uuid is not None
first_tx = (
db.session.query(ver_cls.transaction_id)
.filter(ver_cls.id == chart.id)
@@ -337,7 +340,7 @@ class TestChartRestoreApi(SupersetTestCase):
.scalar()
)
assert first_tx is not None
target_uuid = str(derive_version_uuid(chart.uuid, first_tx))
target_uuid = str(derive_version_uuid(entity_uuid, first_tx))
self.login(ALPHA_USERNAME)
rv = self._restore(str(chart.uuid), target_uuid)
@@ -387,6 +390,8 @@ class TestChartRestoreApi(SupersetTestCase):
assert boys is not None
ver_cls = version_class(Slice)
boys_uuid = boys.uuid
assert boys_uuid is not None
boys_tx = (
db.session.query(ver_cls.transaction_id)
.filter(ver_cls.id == boys.id)
@@ -395,7 +400,7 @@ class TestChartRestoreApi(SupersetTestCase):
.scalar()
)
assert boys_tx is not None
boys_version_uuid = str(derive_version_uuid(boys.uuid, boys_tx))
boys_version_uuid = str(derive_version_uuid(boys_uuid, boys_tx))
self.login(ADMIN_USERNAME)
rv = self._restore(str(girls.uuid), boys_version_uuid)
@@ -421,6 +426,8 @@ class TestChartRestoreApi(SupersetTestCase):
db.session.commit()
ver_cls = version_class(Slice)
entity_uuid = chart.uuid
assert entity_uuid is not None
first_tx = (
db.session.query(ver_cls.transaction_id)
.filter(ver_cls.id == chart_id)
@@ -428,7 +435,7 @@ class TestChartRestoreApi(SupersetTestCase):
.limit(1)
.scalar()
)
target_uuid = str(derive_version_uuid(chart.uuid, first_tx))
target_uuid = str(derive_version_uuid(entity_uuid, first_tx))
self.login(ADMIN_USERNAME)
rv = self._restore(str(chart.uuid), target_uuid)

View File

@@ -90,6 +90,7 @@ class TestDashboardRestoreApi(SupersetTestCase):
original_title = dashboard.dashboard_title
dashboard_id = dashboard.id
entity_uuid = dashboard.uuid
assert entity_uuid is not None
# Make two more edits so we have a known non-trivial history to
# navigate: [initial, v1, v2].
@@ -151,6 +152,7 @@ class TestDashboardRestoreApi(SupersetTestCase):
dashboard_uuid = str(dashboard.uuid)
dashboard_id = dashboard.id
entity_uuid = dashboard.uuid
assert entity_uuid is not None
original_slice_ids = sorted(s.id for s in dashboard.slices)
assert len(original_slice_ids) >= 2, (
@@ -225,6 +227,8 @@ class TestDashboardRestoreApi(SupersetTestCase):
db.session.commit()
ver_cls = version_class(Dashboard)
entity_uuid = dashboard.uuid
assert entity_uuid is not None
target_tx = (
db.session.query(ver_cls.transaction_id)
.filter(ver_cls.id == dashboard_id)
@@ -232,7 +236,7 @@ class TestDashboardRestoreApi(SupersetTestCase):
.limit(1)
.scalar()
)
target_uuid = str(derive_version_uuid(dashboard.uuid, target_tx))
target_uuid = str(derive_version_uuid(entity_uuid, target_tx))
# Edit the member chart AFTER the snapshot.
member = db.session.query(Slice).filter(Slice.id == member_id).one()
@@ -282,6 +286,8 @@ class TestDashboardRestoreApi(SupersetTestCase):
db.session.commit()
ver_cls = version_class(Dashboard)
entity_uuid = dashboard.uuid
assert entity_uuid is not None
target_tx = (
db.session.query(ver_cls.transaction_id)
.filter(ver_cls.id == dashboard_id)
@@ -289,7 +295,7 @@ class TestDashboardRestoreApi(SupersetTestCase):
.limit(1)
.scalar()
)
target_uuid = str(derive_version_uuid(dashboard.uuid, target_tx))
target_uuid = str(derive_version_uuid(entity_uuid, target_tx))
# Detach, then hard-delete the victim via raw SQL so no live row
# remains (bypasses the soft-delete listener deliberately — the
@@ -340,6 +346,8 @@ class TestDashboardRestoreApi(SupersetTestCase):
assert alpha not in dashboard.editors
ver_cls = version_class(Dashboard)
entity_uuid = dashboard.uuid
assert entity_uuid is not None
first_tx = (
db.session.query(ver_cls.transaction_id)
.filter(ver_cls.id == dashboard.id)
@@ -348,7 +356,7 @@ class TestDashboardRestoreApi(SupersetTestCase):
.scalar()
)
assert first_tx is not None
target_uuid = str(derive_version_uuid(dashboard.uuid, first_tx))
target_uuid = str(derive_version_uuid(entity_uuid, first_tx))
self.login(ALPHA_USERNAME)
rv = self._restore(str(dashboard.uuid), target_uuid)

View File

@@ -109,6 +109,7 @@ class TestDatasetRestoreApi(SupersetTestCase):
assert table is not None
table_uuid = str(table.uuid)
entity_uuid = table.uuid
assert entity_uuid is not None
table_id = table.id
original_description = table.description
@@ -164,6 +165,7 @@ class TestDatasetRestoreApi(SupersetTestCase):
assert table is not None
table_uuid = str(table.uuid)
entity_uuid = table.uuid
assert entity_uuid is not None
table_id = table.id
col = table.columns[0]
@@ -219,6 +221,7 @@ class TestDatasetRestoreApi(SupersetTestCase):
table_id = table.id
table_uuid = str(table.uuid)
entity_uuid = table.uuid
assert entity_uuid is not None
original_col_names = sorted(c.column_name for c in table.columns)
removed_name = table.columns[0].column_name
@@ -285,6 +288,7 @@ class TestDatasetRestoreApi(SupersetTestCase):
table_id = table.id
table_uuid = str(table.uuid)
entity_uuid = table.uuid
assert entity_uuid is not None
removed_name = table.columns[0].column_name
added_name = "__restore_full_diff_test__"
@@ -399,6 +403,7 @@ class TestDatasetRestoreApi(SupersetTestCase):
table_id = table.id
table_uuid = str(table.uuid)
entity_uuid = table.uuid
assert entity_uuid is not None
original_description = table.description
original_col_names = sorted(c.column_name for c in table.columns)

View File

@@ -26,7 +26,10 @@ from unittest.mock import patch
import pytest
from superset.commands.report.base import BaseReportScheduleCommand
from superset.commands.report.exceptions import ReportScheduleFrequencyNotAllowed
from superset.commands.report.exceptions import (
ReportScheduleCrontabNotValidError,
ReportScheduleFrequencyNotAllowed,
)
from superset.reports.models import ReportScheduleType
REPORT_TYPES = {
@@ -174,6 +177,28 @@ def test_validate_report_frequency_report_only(schedule: str) -> None:
)
@pytest.mark.parametrize("report_type", REPORT_TYPES)
@app_custom_config(
alert_minimum_interval=int(timedelta(minutes=5).total_seconds()),
report_minimum_interval=int(timedelta(minutes=5).total_seconds()),
)
def test_validate_report_frequency_never_matching_crontab(report_type: str) -> None:
"""
Test the ``validate_report_frequency`` method with a crontab that is
syntactically valid but never matches a real calendar date (Feb 30th).
Such schedules pass ``croniter.is_valid()`` (purely syntactic) and thus
marshmallow schema validation, but raise ``CroniterBadDateError`` when
iterated. This should surface as a ``ValidationError`` rather than
propagating the raw croniter exception.
"""
with pytest.raises(ReportScheduleCrontabNotValidError):
BaseReportScheduleCommand().validate_report_frequency(
"0 0 30 2 *",
report_type,
)
@pytest.mark.parametrize("report_type", REPORT_TYPES)
@pytest.mark.parametrize("schedule", TEST_SCHEDULES)
@app_custom_config(

View File

@@ -0,0 +1,70 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from unittest.mock import MagicMock, patch
import pytest
from superset.common.query_actions import _get_drill_detail
from superset.exceptions import QueryObjectValidationError
def test_get_drill_detail_refuses_datasource_that_opts_out() -> None:
"""
A datasource with ``supports_drill_to_detail = False`` (e.g. semantic
views) must be hard-blocked on the server. Without this gate the request
would fall through to ``_get_full`` and fail with an opaque error, and
the flag would only be enforced by the frontend menu — leaving the
chart-data API endpoint accepting drill-detail requests it shouldn't.
"""
datasource = MagicMock()
datasource.supports_drill_to_detail = False
query_obj = MagicMock()
query_obj.datasource = datasource
query_context = MagicMock()
with pytest.raises(
QueryObjectValidationError,
match="Drill to detail is not available",
):
_get_drill_detail(query_context, query_obj)
def test_get_drill_detail_allows_datasource_without_flag() -> None:
"""
Datasources that don't declare the flag (e.g. legacy ``SqlaTable``
subclasses via ``getattr`` default) must continue to work — the gate
only fires when the flag is explicitly ``False``.
"""
datasource = MagicMock(spec=["columns"])
column = MagicMock()
column.column_name = "id"
datasource.columns = [column]
query_obj = MagicMock()
query_obj.datasource = datasource
query_obj.columns = []
query_context = MagicMock()
expected_payload: dict[str, list[dict[str, str]]] = {"data": []}
with patch(
"superset.common.query_actions._get_full", return_value=expected_payload
) as mock_get_full:
assert _get_drill_detail(query_context, query_obj) is expected_payload
mock_get_full.assert_called_once()

View File

@@ -653,6 +653,15 @@ def test_semantic_view_data(
assert data["table_name"] == "Orders View"
assert data["datasource_name"] == "Orders View"
assert data["offset"] == 0
# Semantic views don't model raw rows, so neither samples nor
# drill-to-detail are available.
assert data["supports_samples"] is False
assert data["supports_drill_to_detail"] is False
def test_semantic_view_supports_samples_is_false() -> None:
"""The class-level flag opts SemanticView out of the Samples affordance."""
assert SemanticView.supports_samples is False
@pytest.fixture
@@ -767,6 +776,11 @@ def test_semantic_view_data_populates_time_grain_sqla(
assert grain_durations == sorted(["PT1H", "P1D", "P1M"])
def test_semantic_view_supports_drill_to_detail_is_false() -> None:
"""The class-level flag opts SemanticView out of Drill to detail."""
assert SemanticView.supports_drill_to_detail is False
def test_semantic_view_get_query_result(
mock_implementation: MagicMock,
) -> None:

View File

@@ -33,6 +33,10 @@ from superset.utils.screenshots import (
BASE_SCREENSHOT_PATH = "superset.utils.screenshots.BaseScreenshot"
# A minimal valid PNG header, used wherever a test needs bytes that pass
# ScreenshotCachePayload's image validation.
FAKE_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"fake-png-body"
class MockCache:
"""A class to manage screenshot cache."""
@@ -92,7 +96,7 @@ def test_get_cache_key(app_context, screenshot_obj):
def test_get_from_cache_key(mocker: MockerFixture, screenshot_obj):
"""get_from_cache_key should always return a ScreenshotCachePayload Object"""
# backwards compatibility test for retrieving plain bytes
fake_bytes = b"fake_screenshot_data"
fake_bytes = FAKE_PNG_BYTES
BaseScreenshot.cache = MockCache()
BaseScreenshot.cache.set("key", fake_bytes)
cache_payload = screenshot_obj.get_from_cache_key("key")
@@ -108,10 +112,10 @@ class TestComputeAndCache:
BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None
)
get_screenshot = mocker.patch(
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"new_image_data"
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=FAKE_PNG_BYTES
)
resize_image = mocker.patch(
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data"
BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES
)
BaseScreenshot.cache = MockCache()
return {

View File

@@ -37,6 +37,10 @@ from superset.utils.screenshots import (
BASE_SCREENSHOT_PATH = "superset.utils.screenshots.BaseScreenshot"
DISTRIBUTED_LOCK_PATH = "superset.utils.screenshots.DistributedLock"
# A minimal valid PNG header, used wherever a test needs bytes that pass
# ScreenshotCachePayload's image validation.
FAKE_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"fake-png-body"
class MockCache:
"""A class to manage screenshot cache for testing."""
@@ -83,11 +87,11 @@ class TestCacheOnlyOnSuccess:
mocker.patch(DISTRIBUTED_LOCK_PATH)
mocker.patch(BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None)
get_screenshot = mocker.patch(
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"image_data"
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=FAKE_PNG_BYTES
)
# Mock resize_image to avoid PIL errors with fake image data
mocker.patch(
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data"
BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES
)
BaseScreenshot.cache = MockCache()
return get_screenshot
@@ -161,13 +165,15 @@ class TestCacheOnlyOnSuccess:
screenshot_obj: BaseScreenshot,
mock_user: MagicMock,
) -> None:
"""Empty bytes from get_screenshot must set ERROR, not leave COMPUTING."""
"""Empty bytes from get_screenshot must set ERROR, not leave COMPUTING,
and must log a WARNING that includes the cache key."""
mocker.patch(DISTRIBUTED_LOCK_PATH)
mocker.patch(BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None)
mocker.patch(
BASE_SCREENSHOT_PATH + ".get_screenshot",
return_value=b"",
)
mock_logger = mocker.patch("superset.utils.screenshots.logger")
BaseScreenshot.cache = MockCache()
screenshot_obj.compute_and_cache(user=mock_user, force=True)
@@ -177,6 +183,43 @@ class TestCacheOnlyOnSuccess:
assert cached_value is not None
assert cached_value["status"] == "Error"
assert cached_value.get("image") is None
assert any(
cache_key in call.args and "empty" in call.args
for call in mock_logger.warning.call_args_list
)
def test_cache_error_status_when_screenshot_returns_garbage_bytes(
self,
mocker: MockerFixture,
screenshot_obj: BaseScreenshot,
mock_user: MagicMock,
) -> None:
"""Non-empty bytes without a valid image header must set ERROR, not be
cached as a success, and must log a WARNING that includes the cache key."""
mocker.patch(DISTRIBUTED_LOCK_PATH)
mocker.patch(BASE_SCREENSHOT_PATH + ".get_from_cache_key", return_value=None)
mocker.patch(
BASE_SCREENSHOT_PATH + ".get_screenshot",
return_value=b"this-is-not-a-real-image",
)
mocker.patch(
BASE_SCREENSHOT_PATH + ".resize_image",
return_value=b"this-is-not-a-real-image",
)
mock_logger = mocker.patch("superset.utils.screenshots.logger")
BaseScreenshot.cache = MockCache()
screenshot_obj.compute_and_cache(user=mock_user, force=True)
cache_key = screenshot_obj.get_cache_key()
cached_value = BaseScreenshot.cache.get(cache_key)
assert cached_value is not None
assert cached_value["status"] == "Error"
assert cached_value.get("image") is None
assert any(
cache_key in call.args and "undecodable" in call.args
for call in mock_logger.warning.call_args_list
)
def test_computing_status_written_to_cache_early(
self,
@@ -197,14 +240,14 @@ class TestCacheOnlyOnSuccess:
"Cache should be set to COMPUTING before screenshot starts"
)
assert cached_value["status"] == "Computing"
return b"image_data"
return FAKE_PNG_BYTES
mocker.patch(
BASE_SCREENSHOT_PATH + ".get_screenshot",
side_effect=check_cache_during_screenshot,
)
mocker.patch(
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image_data"
BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES
)
screenshot_obj.compute_and_cache(user=mock_user, force=True)
@@ -429,11 +472,11 @@ class TestIntegrationCacheBugFix:
BaseScreenshot.cache.set(cache_key, stale_payload.to_dict())
mocker.patch(
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=b"recovered_image"
BASE_SCREENSHOT_PATH + ".get_screenshot", return_value=FAKE_PNG_BYTES
)
# Mock resize to avoid PIL errors
mocker.patch(
BASE_SCREENSHOT_PATH + ".resize_image", return_value=b"resized_image"
BASE_SCREENSHOT_PATH + ".resize_image", return_value=FAKE_PNG_BYTES
)
# Should trigger task because COMPUTING is stale
@@ -482,3 +525,72 @@ class TestIntegrationCacheBugFix:
assert payload._image == old_image
assert payload.status == StatusValues.COMPUTING
class TestReadSideImageValidation:
"""A cached payload that claims a successful screenshot (status UPDATED)
but carries invalid image bytes must be served as a cache miss, not
returned to the caller — this is what the dashboard/chart screenshot
endpoints call to fetch bytes to serve."""
def test_zero_byte_image_is_treated_as_cache_miss(
self, mocker: MockerFixture, screenshot_obj: BaseScreenshot
) -> None:
mock_logger = mocker.patch("superset.utils.screenshots.logger")
BaseScreenshot.cache = MockCache()
cache_key = screenshot_obj.get_cache_key()
stale_payload = ScreenshotCachePayload(image=b"", status=StatusValues.UPDATED)
BaseScreenshot.cache.set(cache_key, stale_payload.to_dict())
result = screenshot_obj.get_from_cache_key(cache_key)
assert result is None
assert any(
cache_key in call.args and "empty" in call.args
for call in mock_logger.warning.call_args_list
)
def test_garbage_bytes_image_is_treated_as_cache_miss(
self, mocker: MockerFixture, screenshot_obj: BaseScreenshot
) -> None:
mock_logger = mocker.patch("superset.utils.screenshots.logger")
BaseScreenshot.cache = MockCache()
cache_key = screenshot_obj.get_cache_key()
garbage_payload = ScreenshotCachePayload(image=b"not-an-image-at-all")
BaseScreenshot.cache.set(cache_key, garbage_payload.to_dict())
result = screenshot_obj.get_from_cache_key(cache_key)
assert result is None
assert any(
cache_key in call.args and "undecodable" in call.args
for call in mock_logger.warning.call_args_list
)
def test_valid_image_is_served_normally(
self, screenshot_obj: BaseScreenshot
) -> None:
BaseScreenshot.cache = MockCache()
cache_key = screenshot_obj.get_cache_key()
valid_payload = ScreenshotCachePayload(image=FAKE_PNG_BYTES)
BaseScreenshot.cache.set(cache_key, valid_payload.to_dict())
result = screenshot_obj.get_from_cache_key(cache_key)
assert result is not None
assert result.get_image().read() == FAKE_PNG_BYTES
def test_pending_status_with_no_image_is_not_rejected(
self, screenshot_obj: BaseScreenshot
) -> None:
"""Non-UPDATED statuses (e.g. PENDING/COMPUTING) aren't claiming a
successful screenshot, so they should be returned as-is."""
BaseScreenshot.cache = MockCache()
cache_key = screenshot_obj.get_cache_key()
pending_payload = ScreenshotCachePayload(status=StatusValues.PENDING)
BaseScreenshot.cache.set(cache_key, pending_payload.to_dict())
result = screenshot_obj.get_from_cache_key(cache_key)
assert result is not None
assert result.status == StatusValues.PENDING

View File

@@ -25,8 +25,11 @@ from superset.utils.screenshot_utils import (
combine_screenshot_tiles,
resolve_screenshot_task_budget_seconds,
SCREENSHOT_TASK_BUDGET_MAX_MARGIN_SECONDS,
ScreenshotTaskBudgetExceededError,
SCROLL_SETTLE_TIMEOUT_MS,
take_tiled_screenshot,
TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS,
TiledScreenshotBudgetExceededError,
)
@@ -453,12 +456,17 @@ class TestTakeTiledScreenshot:
assert warning_args[2] == 1 # count of unready chart containers
assert warning_args[3] == 1 # tile index
assert warning_args[4] == 3 # total tiles
assert warning_args[5] == 30 # load_wait
assert warning_args[6] == "" # no log_context passed
assert warning_args[5] == 30 # tile_load_wait (uncapped: budget remains)
assert warning_args[6] == 30 # requested load_wait
assert isinstance(warning_args[7], float) # total elapsed vs budget
assert warning_args[8] == 1440 # total budget (fixed fallback)
assert warning_args[9] == 0 # tiles captured so far
assert warning_args[10] == 3 # total tiles
assert warning_args[11] == "" # no log_context passed
# Diagnostic payload identifies chart id AND the state it's stuck in
# (spinner mounted vs nothing mounted vs waiting-on-database) so a
# slow query can be told apart from the virtualization race.
assert warning_args[7] == [{"chartId": "42", "state": "waiting_on_database"}]
assert warning_args[12] == [{"chartId": "42", "state": "waiting_on_database"}]
def test_timeout_warning_includes_log_context(self, mock_page):
"""The log context (e.g. report execution id) is threaded through for
@@ -484,7 +492,7 @@ class TestTakeTiledScreenshot:
)
warning_args = mock_logger.warning.call_args[0]
assert warning_args[6] == " [execution_id=abc-123]"
assert warning_args[11] == " [execution_id=abc-123]"
def test_chart_holder_with_nothing_mounted_blocks_wait(self, mock_page):
"""Regression test for the vacuous-pass race (PR #39895).
@@ -646,3 +654,311 @@ class TestTakeTiledScreenshot:
sig = inspect.signature(take_tiled_screenshot)
assert sig.parameters["animation_wait"].default == 0
class TestTileWaitBudget:
"""The tiled operation's cumulative per-tile waits are capped by one
wall-clock budget derived from the running Celery task's own time limit
(resolve_screenshot_task_budget_seconds), falling back to a fixed total
ceiling outside Celery because per-tile waits accumulate."""
@pytest.fixture
def mock_page(self):
"""Create a mock Playwright page object for a 3-tile (5000px) dashboard."""
page = MagicMock()
element = MagicMock()
page.locator.return_value = element
page.evaluate.return_value = {
"height": 5000,
"top": 100,
"left": 50,
"width": 800,
}
page.screenshot.return_value = b"fake_screenshot_data"
return page
class _FakeClock:
"""Stateful monotonic() stand-in the test advances explicitly.
Robust to how many times the code under test samples the clock per
tile (budget check, per-tile wait timing, animation budget) -- only
explicit advances move time forward.
"""
def __init__(self) -> None:
self.now = 0.0
def __call__(self) -> float:
return self.now
def test_budget_error_is_task_budget_error_subclass(self):
"""Callers can catch the whole budget-error family with the base
ScreenshotTaskBudgetExceededError type."""
assert issubclass(
TiledScreenshotBudgetExceededError, ScreenshotTaskBudgetExceededError
)
def test_per_tile_wait_shrinks_as_budget_depletes(self, mock_page, monkeypatch):
"""Each tile's readiness-wait timeout is capped at the remaining budget."""
monkeypatch.setattr(
"superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501
1000,
)
clock = self._FakeClock()
# Simulate slow tiles: the readiness wait itself consumes wall time,
# so each subsequent tile sees less remaining budget.
wait_durations = iter([950, 40, 5])
def slow_wait(*args, **kwargs):
clock.now += next(wait_durations)
mock_page.wait_for_function.side_effect = slow_wait
with patch("superset.utils.screenshot_utils.current_task", None):
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
result = take_tiled_screenshot(
mock_page, "dashboard", tile_height=2000, load_wait=100
)
assert result is not None
timeouts = [
call[1]["timeout"] for call in mock_page.wait_for_function.call_args_list
]
# remaining budget at each tile's wait: 1000, 50, 10 seconds
# -> capped timeouts shrink
assert timeouts == [100 * 1000, 50 * 1000, 10 * 1000]
assert timeouts == sorted(timeouts, reverse=True)
def test_readiness_wait_uses_budget_recomputed_after_scroll_settle(
self, mock_page, monkeypatch
):
"""The readiness-wait timeout must be capped using the budget
recomputed *after* the scroll-settle sleep, not the stale value from
before it -- otherwise each tile could overrun the total budget by up
to one settle interval."""
monkeypatch.setattr(
"superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501
1000,
)
# A single-tile dashboard to keep the scenario simple.
mock_page.evaluate.return_value = {
"height": 1000,
"top": 100,
"left": 50,
"width": 800,
}
clock = self._FakeClock()
# The scroll-settle sleep itself consumes 950s of wall-clock time,
# leaving only 50s of the 1000s budget by the time the readiness
# wait is capped.
mock_page.wait_for_timeout.side_effect = lambda *args, **kwargs: setattr(
clock, "now", clock.now + 950
)
with patch("superset.utils.screenshot_utils.current_task", None):
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(
mock_page, "dashboard", tile_height=2000, load_wait=999
)
timeout = mock_page.wait_for_function.call_args_list[0][1]["timeout"]
# Must reflect the post-settle remaining budget (50s), not the
# stale pre-settle value (1000s, which would have let load_wait's
# full 999s through uncapped).
assert timeout == 50 * 1000
def test_budget_exhausted_raises_and_stops_capturing(self, mock_page, monkeypatch):
"""Exhausting the budget aborts cleanly instead of capturing unchecked."""
monkeypatch.setattr(
"superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501
1000,
)
clock = self._FakeClock()
# Tile 0's readiness wait consumes the whole budget; tile 1's budget
# check then sees remaining <= 0 and raises before capturing.
mock_page.wait_for_function.side_effect = lambda *args, **kwargs: setattr(
clock, "now", 1000.0
)
with patch("superset.utils.screenshot_utils.current_task", None):
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
with patch(
"superset.utils.screenshot_utils.combine_screenshot_tiles"
) as mock_combine:
with patch("superset.utils.screenshot_utils.logger") as mock_logger:
with pytest.raises(TiledScreenshotBudgetExceededError):
take_tiled_screenshot(
mock_page, "dashboard", tile_height=2000, load_wait=100
)
# Only the first tile was captured before the budget ran out.
assert mock_page.screenshot.call_count == 1
# Tiles were never combined -- the function raised before that point.
mock_combine.assert_not_called()
# Budget exhaustion is a customer chart-loading issue, not a Superset
# system fault, so it must log at WARNING (not ERROR) -- consistent
# with the #38130/#38441 precedent for screenshot timeout logging.
assert mock_logger.error.call_count == 0
mock_logger.warning.assert_called_once()
warning_args = mock_logger.warning.call_args[0]
assert "budget exhausted" in warning_args[0]
# tile index, tiles total, tiles captured, tiles total,
# elapsed seconds, budget seconds, log-context suffix
assert warning_args[1] == 2
assert warning_args[2] == 3
assert warning_args[3] == 1
assert warning_args[4] == 3
assert warning_args[5] == 1000
assert warning_args[6] == 1000
assert warning_args[7] == ""
def test_budget_exhausted_warning_includes_log_context(
self, mock_page, monkeypatch
):
"""log_context (e.g. report execution id) is appended to the warning."""
monkeypatch.setattr(
"superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501
1000,
)
clock = self._FakeClock()
# Tile 0's readiness wait consumes the whole budget; tile 1's budget
# check then sees remaining <= 0 and raises.
mock_page.wait_for_function.side_effect = lambda *args, **kwargs: setattr(
clock, "now", 1000.0
)
with patch("superset.utils.screenshot_utils.current_task", None):
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
with patch("superset.utils.screenshot_utils.logger") as mock_logger:
with pytest.raises(TiledScreenshotBudgetExceededError):
take_tiled_screenshot(
mock_page,
"dashboard",
tile_height=2000,
load_wait=100,
log_context="execution_id=abc-123",
)
warning_args = mock_logger.warning.call_args[0]
assert warning_args[-1] == " [execution_id=abc-123]"
def test_budget_exhausted_before_first_tile_raises_without_capture(
self, mock_page, monkeypatch
):
"""No budget floor: a budget already exhausted by setup (element
lookup/dimension probing) raises before the first tile is captured,
matching the non-tiled path's raise-before-capture semantics."""
monkeypatch.setattr(
"superset.utils.screenshot_utils.TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS", # noqa: E501
1000,
)
clock = self._FakeClock()
# The dashboard-dimension evaluate() itself consumes the whole budget.
original_return = {"height": 5000, "top": 100, "left": 50, "width": 800}
def slow_evaluate(*args, **kwargs):
clock.now = 1000.0
return original_return
mock_page.evaluate.side_effect = slow_evaluate
with patch("superset.utils.screenshot_utils.current_task", None):
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
with patch(
"superset.utils.screenshot_utils.combine_screenshot_tiles"
) as mock_combine:
with pytest.raises(TiledScreenshotBudgetExceededError):
take_tiled_screenshot(
mock_page, "dashboard", tile_height=2000, load_wait=100
)
mock_page.screenshot.assert_not_called()
mock_combine.assert_not_called()
def test_no_celery_context_uses_fixed_total_fallback(self, mock_page):
"""Outside Celery the helper returns None; the tiled path must fall
back to the fixed total ceiling rather than running uncapped, because
per-tile waits accumulate across tiles."""
clock = self._FakeClock()
with patch("superset.utils.screenshot_utils.current_task", None):
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(
mock_page,
"dashboard",
tile_height=2000,
load_wait=10_000, # deliberately above the fallback
)
first_timeout = mock_page.wait_for_function.call_args_list[0][1]["timeout"]
assert first_timeout == TILED_SCREENSHOT_TOTAL_WAIT_BUDGET_SECONDS * 1000
def test_derived_task_budget_caps_tile_wait(self, mock_page):
"""Inside Celery, the tiled path caps waits using the same
task-derived budget as the non-tiled path (helper reuse, #42427)."""
task = MagicMock()
task.request.timelimit = (120, None) # (hard, soft): 120s hard limit
clock = self._FakeClock()
with patch("superset.utils.screenshot_utils.current_task", task):
with patch("superset.utils.screenshot_utils.time.monotonic", new=clock):
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(
mock_page, "dashboard", tile_height=2000, load_wait=200
)
# margin = min(300, 120 * 0.2) = 24; budget = 120 - 24 = 96
first_timeout = mock_page.wait_for_function.call_args_list[0][1]["timeout"]
assert first_timeout == 96 * 1000
assert first_timeout < 200 * 1000
def test_fast_dashboard_matches_default_behavior(self, mock_page):
"""Well under budget, waits are not capped and behavior is unchanged."""
with patch("superset.utils.screenshot_utils.current_task", None):
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
result = take_tiled_screenshot(
mock_page,
"dashboard",
tile_height=2000,
load_wait=30,
animation_wait=5,
)
assert result is not None
assert mock_page.screenshot.call_count == 3
for call in mock_page.wait_for_function.call_args_list:
assert call[1]["timeout"] == 30 * 1000
animation_calls = [
call
for call in mock_page.wait_for_timeout.call_args_list
if call[0][0] == 5 * 1000
]
assert len(animation_calls) == 3
def test_per_tile_timing_debug_line_logged(self, mock_page):
"""Each tile logs a DEBUG timing breakdown (readiness wait, animation
wait) so slow dashboards can be profiled from logs alone."""
with patch("superset.utils.screenshot_utils.current_task", None):
with patch("superset.utils.screenshot_utils.logger") as mock_logger:
with patch("superset.utils.screenshot_utils.combine_screenshot_tiles"):
take_tiled_screenshot(
mock_page,
"dashboard",
tile_height=2000,
log_context="cache_key=xyz",
)
timing_calls = [
call for call in mock_logger.debug.call_args_list if "timing" in call[0][0]
]
assert len(timing_calls) == 3
for i, call in enumerate(timing_calls):
args = call[0]
assert args[1] == i + 1 # tile index
assert args[2] == 3 # total tiles
assert args[-1] == " [cache_key=xyz]"

View File

@@ -919,12 +919,185 @@ class TestWebDriverPlaywrightErrorHandling:
)
assert result == b"fake_screenshot"
mock_logger.warning.assert_any_call(
"Could not determine dashboard height for element %s at url %s; "
"falling back to standard screenshot behavior",
# chart_count (1) is well below the tiling threshold (20), so this is
# the benign/expected case and must not be logged as a WARNING.
mock_logger.debug.assert_any_call(
"Could not determine dashboard height for element %s "
"at url %s (%s chart containers found); %s",
"dashboard",
"http://example.com",
1,
"falling back to standard screenshot behavior",
)
assert not any(
call.args and "Could not determine dashboard height" in call.args[0]
for call in mock_logger.warning.call_args_list
)
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
@patch("superset.utils.webdriver._browser_manager")
@patch("superset.utils.webdriver.logger")
@patch("superset.utils.webdriver.take_tiled_screenshot")
def test_unknown_height_does_not_veto_tiling_for_large_dashboard(
self, mock_take_tiled, mock_logger, mock_browser_manager
):
"""
A large dashboard (by chart_count) whose height can't be measured
must still attempt tiling instead of being silently downgraded to a
standard screenshot, since below-the-fold charts may not have
rendered without the scroll-driven tiling pass.
"""
mock_user = MagicMock()
mock_user.username = "test_user"
mock_browser = MagicMock()
mock_context = MagicMock()
mock_page = MagicMock()
mock_element = MagicMock()
mock_chart_container = MagicMock()
mock_browser_manager.get_browser.return_value = mock_browser
mock_browser.new_context.return_value = mock_context
mock_context.new_page.return_value = mock_page
def locator_side_effect(selector):
if selector == ".chart-container":
locator = MagicMock()
locator.all.return_value = [mock_chart_container]
return locator
return mock_element
mock_page.locator.side_effect = locator_side_effect
mock_element.wait_for.return_value = None
mock_chart_container.wait_for.return_value = None
mock_page.wait_for_timeout.return_value = None
mock_take_tiled.return_value = b"tiled_screenshot"
def evaluate_side_effect(script):
if script == 'document.querySelectorAll(".chart-container").length':
return 25 # chart_count >= threshold
if "const target = document.querySelector" in script:
return 0 # height could not be determined
return None
mock_page.evaluate.side_effect = evaluate_side_effect
with patch("superset.utils.webdriver.app") as mock_app:
mock_app.config = {
"WEBDRIVER_OPTION_ARGS": [],
"WEBDRIVER_WINDOW": {"pixel_density": 1},
"SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT": 30000,
"SCREENSHOT_PLAYWRIGHT_WAIT_EVENT": "networkidle",
"SCREENSHOT_SELENIUM_HEADSTART": 5,
"SCREENSHOT_SELENIUM_ANIMATION_WAIT": 1,
"SCREENSHOT_LOCATE_WAIT": 10,
"SCREENSHOT_LOAD_WAIT": 10,
"SCREENSHOT_WAIT_FOR_ERROR_MODAL_VISIBLE": 10,
"SCREENSHOT_WAIT_FOR_ERROR_MODAL_INVISIBLE": 10,
"SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False,
"SCREENSHOT_TILED_ENABLED": True,
"SCREENSHOT_TILED_CHART_THRESHOLD": 20,
"SCREENSHOT_TILED_HEIGHT_THRESHOLD": 5000,
"SCREENSHOT_TILED_VIEWPORT_HEIGHT": 600,
}
with patch.object(WebDriverPlaywright, "auth") as mock_auth:
mock_auth.return_value = mock_context
driver = WebDriverPlaywright("chrome")
result = driver.get_screenshot(
"http://example.com", "dashboard", mock_user
)
assert result == b"tiled_screenshot"
mock_take_tiled.assert_called_once()
mock_logger.warning.assert_any_call(
"Could not determine dashboard height for element %s "
"at url %s (%s chart containers found); %s",
"dashboard",
"http://example.com",
25,
"attempting tiled screenshot anyway",
)
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
@patch("superset.utils.webdriver._browser_manager")
@patch("superset.utils.webdriver.logger")
def test_chart_container_timeout_logs_warning_with_progress_and_raises(
self, mock_logger, mock_browser_manager
):
"""
Timing out while waiting for `.chart-container` elements to draw must
be logged as a WARNING (matching the other locate-wait timeouts in
this method, and the customer-side-slowness convention established
for these Playwright timeouts) with rendered/total progress, and must
still fail the screenshot by re-raising.
"""
from superset.utils.webdriver import PlaywrightTimeout
mock_user = MagicMock()
mock_user.username = "test_user"
mock_browser = MagicMock()
mock_context = MagicMock()
mock_page = MagicMock()
mock_element = MagicMock()
mock_browser_manager.get_browser.return_value = mock_browser
mock_browser.new_context.return_value = mock_context
mock_context.new_page.return_value = mock_page
timeout = PlaywrightTimeout()
rendered_ok = MagicMock()
rendered_ok.wait_for.return_value = None
never_renders = MagicMock()
never_renders.wait_for.side_effect = timeout
def locator_side_effect(selector):
if selector == ".chart-container":
locator = MagicMock()
locator.all.return_value = [rendered_ok, never_renders]
return locator
return mock_element
mock_page.locator.side_effect = locator_side_effect
mock_element.wait_for.return_value = None
with patch("superset.utils.webdriver.app") as mock_app:
mock_app.config = {
"WEBDRIVER_OPTION_ARGS": [],
"WEBDRIVER_WINDOW": {"pixel_density": 1},
"SCREENSHOT_PLAYWRIGHT_DEFAULT_TIMEOUT": 30000,
"SCREENSHOT_PLAYWRIGHT_WAIT_EVENT": "networkidle",
"SCREENSHOT_SELENIUM_HEADSTART": 5,
"SCREENSHOT_SELENIUM_ANIMATION_WAIT": 1,
"SCREENSHOT_LOCATE_WAIT": 10,
"SCREENSHOT_LOAD_WAIT": 10,
"SCREENSHOT_WAIT_FOR_ERROR_MODAL_VISIBLE": 10,
"SCREENSHOT_WAIT_FOR_ERROR_MODAL_INVISIBLE": 10,
"SCREENSHOT_REPLACE_UNEXPECTED_ERRORS": False,
"SCREENSHOT_TILED_ENABLED": False,
}
with patch.object(WebDriverPlaywright, "auth") as mock_auth:
mock_auth.return_value = mock_context
driver = WebDriverPlaywright("chrome")
with pytest.raises(PlaywrightTimeout) as exc_info:
driver.get_screenshot(
"http://example.com", "test-element", mock_user
)
assert exc_info.value is timeout
mock_logger.warning.assert_any_call(
"Timed out waiting for chart containers to draw at url %s "
"(%s of %s chart containers rendered before the timeout)",
"http://example.com",
1,
2,
exc_info=True,
)
mock_logger.exception.assert_not_called()
@patch("superset.utils.webdriver.PLAYWRIGHT_AVAILABLE", True)
@patch("superset.utils.webdriver._browser_manager")

View File

@@ -310,3 +310,66 @@ def test_save_non_editor_with_editors_field_is_rejected(
raw_save(_view_self())
mock_security_manager.raise_for_editorship.assert_called_once_with(mock_orm)
# ---------------------------------------------------------------------------
# Datasource.samples
# ---------------------------------------------------------------------------
@patch("superset.views.datasource.views._", lambda s: s)
@patch("superset.views.datasource.views.get_samples")
@patch("superset.views.datasource.views.json_error_response")
@patch("superset.views.datasource.views.security_manager", new_callable=MagicMock)
def test_samples_returns_400_for_unsupported_datasource_type(
mock_security_manager: MagicMock,
mock_json_error_response: MagicMock,
mock_get_samples: MagicMock,
) -> None:
"""Semantic views can't return raw samples — endpoint should refuse with 400."""
from flask import Flask
mock_security_manager.is_guest_user.return_value = False
mock_json_error_response.return_value = "error-response"
raw_samples = _get_view_func("samples")
app = Flask(__name__)
with app.test_request_context(
"/datasource/samples?datasource_type=semantic_view&datasource_id=1",
method="POST",
json={},
):
result = raw_samples(_view_self())
assert result == "error-response"
mock_json_error_response.assert_called_once()
_, kwargs = mock_json_error_response.call_args
assert kwargs.get("status") == 400
# The bail-out must happen before any sample fetching is attempted.
mock_get_samples.assert_not_called()
@patch("superset.views.datasource.views.get_samples")
@patch("superset.views.datasource.views.security_manager", new_callable=MagicMock)
def test_samples_proceeds_for_supported_datasource_type(
mock_security_manager: MagicMock,
mock_get_samples: MagicMock,
) -> None:
"""A `query` datasource (supports_samples=True) bypasses the 400 short-circuit."""
from flask import Flask
mock_security_manager.is_guest_user.return_value = False
mock_get_samples.return_value = {"rows": []}
view = _view_self()
raw_samples = _get_view_func("samples")
app = Flask(__name__)
with app.test_request_context(
"/datasource/samples?datasource_type=query&datasource_id=1",
method="POST",
json={},
):
raw_samples(view)
mock_get_samples.assert_called_once()
view.json_response.assert_called_once_with({"result": {"rows": []}})