From c6bf8c4a46eb12fad173c081df63e85ed4f1ccc6 Mon Sep 17 00:00:00 2001 From: Damian Pendrak Date: Thu, 5 Nov 2026 07:52:48 +0100 Subject: [PATCH 1/2] feat(time-range-limits): add time range limits for chart queries --- UPDATING.md | 6 + .../configuration/time-range-limits.mdx | 269 ++ docs/docs/using-superset/issue-codes.mdx | 17 + docs/static/feature-flags.json | 7 + docs/static/resources/openapi.json | 1434 +++++- .../src/components/Icons/AntdEnhanced.tsx | 2 + .../src/components/Radio/Radio.test.tsx | 69 + .../src/components/Radio/index.tsx | 24 +- .../superset-ui-core/src/query/types/Query.ts | 1 + .../src/time-comparison/fetchTimeRange.ts | 116 + .../src/time-comparison/index.ts | 15 +- .../src/time-comparison/types.ts | 26 + .../src/ui-overrides/types.ts | 2 + .../src/utils/featureFlags.ts | 1 + .../time-comparison/fetchTimeRange.test.ts | 272 +- .../test/utils/featureFlag.test.ts | 9 + .../spec/fixtures/dateRangeParity.json | 2411 ++++++++++ .../spec/helpers/reducerIndex.ts | 2 + .../src/components/Chart/chartAction.ts | 87 +- .../src/components/Chart/chartActions.test.ts | 45 + .../DatasourceModal.timeRangeLimits.test.tsx | 1197 +++++ .../Datasource/DatasourceModal/index.tsx | 140 +- .../DatasourceEditor/DatasourceEditor.tsx | 14 +- .../tests/DatasourceEditor.test.tsx | 141 + .../OAuth2RedirectMessage.test.tsx | 58 +- .../ErrorMessage/OAuth2RedirectMessage.tsx | 4 +- .../TimeRangeLimitsSection.test.tsx | 1691 +++++++ .../TimeRangeLimitsSection.tsx | 776 ++++ .../TimeRangeLimits/limitAlertIcon.test.tsx | 64 + .../TimeRangeLimits/limitAlertIcon.tsx | 38 + .../components/TimeRangeLimits/policyDraft.ts | 292 ++ .../TimeRangeLimits/useSaveCheck.test.ts | 480 ++ .../TimeRangeLimits/useSaveCheck.ts | 108 + .../TimeRangeLimits/useTimeRangeLimits.ts | 476 ++ .../dashboard/actions/dashboardState.test.ts | 67 + .../src/dashboard/actions/dashboardState.ts | 7 + .../dashboard/actions/timeRangeLimits.test.ts | 1267 +++++ .../src/dashboard/actions/timeRangeLimits.ts | 603 +++ .../DashboardBuilder.editorNotice.test.tsx | 656 +++ .../DashboardBuilder.timeRangeLimits.test.tsx | 1423 ++++++ .../DashboardBuilder/DashboardBuilder.tsx | 16 + .../DashboardBuilder/TimeRangeLimitsAlert.tsx | 648 +++ .../TimeRangeLimitsEditorNotice.tsx | 129 + .../PropertiesModal.timeRangeLimits.test.tsx | 1611 +++++++ .../components/PropertiesModal/index.tsx | 230 +- .../Chart/Chart.timeRangeLimits.test.tsx | 854 ++++ .../components/gridComponents/Chart/Chart.tsx | 100 +- .../Chart/HeldChartPlaceholder.tsx | 55 + .../Chart/useChartTimeRangeHold.ts | 138 + .../ActionButtons/ActionButtons.test.tsx | 28 + .../FilterBar/ActionButtons/index.tsx | 3 + .../FilterBar.timeRangeLimits.test.tsx | 785 ++++ .../FilterControls/FilterControl.tsx | 5 +- .../FilterControls/FilterValue.test.tsx | 4 +- .../FilterValue.timeRangeLimits.test.tsx | 586 +++ .../FilterBar/FilterControls/FilterValue.tsx | 134 +- .../nativeFilters/FilterBar/index.tsx | 88 +- .../nativeFilters/FilterBar/utils.test.ts | 177 + .../nativeFilters/FilterBar/utils.ts | 175 +- .../ConfigModalContent/ConfigModalContent.tsx | 4 + .../FilterContentRenderer.tsx | 5 + .../FiltersConfigForm/DefaultValue.tsx | 19 +- ...Form.timeRangeLimits.defaultValue.test.tsx | 469 ++ ...m.timeRangeLimits.optionQueryHold.test.tsx | 276 ++ ...onfigForm.timeRangeLimits.preview.test.tsx | 263 ++ ...Form.timeRangeLimits.previewReset.test.tsx | 473 ++ ...FiltersConfigForm.timeRangeLimits.test.tsx | 499 ++ ...ConfigForm.timeRangeLimits.testHelpers.tsx | 370 ++ .../FiltersConfigForm/FiltersConfigForm.tsx | 196 +- .../TimeRangeLimitsAlerts.tsx | 88 + .../FiltersConfigForm/state.ts | 7 +- .../useFilterTimeRangeLimits.tsx | 371 ++ .../FiltersConfigModal/FiltersConfigModal.tsx | 29 + .../hooks/useModalSaveLogic.ts | 28 +- .../useDashboardTimeRangeLimits.test.ts | 267 ++ .../useDashboardTimeRangeLimits.ts | 101 + .../FiltersConfigModal/utils.test.ts | 165 +- .../nativeFilters/FiltersConfigModal/utils.ts | 53 + .../components/nativeFilters/utils.ts | 8 + .../containers/DashboardPage.test.tsx | 51 + .../dashboard/containers/DashboardPage.tsx | 7 + .../reducers/timeRangeLimits.test.ts | 228 + .../src/dashboard/reducers/timeRangeLimits.ts | 206 + superset-frontend/src/dashboard/types.ts | 2 + .../explore/actions/hydrateExplore.test.ts | 85 + .../src/explore/actions/hydrateExplore.ts | 19 +- .../actions/timeRangeLimitsActions.test.ts | 860 ++++ .../explore/actions/timeRangeLimitsActions.ts | 576 +++ .../ControlPanelsContainer.test.tsx | 321 ++ .../components/ControlPanelsContainer.tsx | 167 +- .../DataTablesPane/DataTablesPane.tsx | 2 + .../DataTablesPane/components/SamplesPane.tsx | 4 +- .../DataTablesPane/test/SamplesPane.test.tsx | 28 + .../components/DataTablesPane/types.ts | 2 + .../src/explore/components/ExploreAlert.tsx | 8 +- .../ExploreChartHeader.test.tsx | 39 + .../components/ExploreChartHeader/index.tsx | 5 +- .../components/ExploreChartPanel/index.tsx | 215 +- .../ExploreChartPanel/useTimeRangeAlert.tsx | 73 + ...ntainer.timeRangeLimits.chartArea.test.tsx | 549 +++ ...ewContainer.timeRangeLimits.focus.test.tsx | 515 +++ ...loreViewContainer.timeRangeLimits.test.tsx | 941 ++++ ...wContainer.timeRangeLimits.testHelpers.tsx | 418 ++ ...imeRangeLimits.timeCompareOptions.test.tsx | 330 ++ .../components/ExploreViewContainer/index.tsx | 131 +- .../useExploreTimeRangeCheck.ts | 387 ++ .../useExploreTimeRangeLimits.tsx | 504 ++ .../RunQueryButton/RunQueryButton.test.tsx | 20 + .../components/RunQueryButton/index.tsx | 3 + .../ControlPopover/ControlPopover.tsx | 4 +- .../DatasourceControl.test.tsx | 27 + .../controls/DatasourceControl/index.tsx | 2 + .../DateFilterControl/DateFilterLabel.tsx | 174 +- .../ExploreTimeRangeLimitsContext.test.tsx | 91 + .../ExploreTimeRangeLimitsContext.tsx | 47 + .../components/AdvancedFrame.tsx | 40 +- .../components/CalendarFrame.tsx | 25 +- .../components/CommonFrame.tsx | 21 +- .../components/CurrentCalendarFrame.tsx | 23 +- .../components/CustomFrame.tsx | 314 +- .../components/DateLabel.tsx | 28 +- .../components/limitFields.tsx | 145 + .../tests/AdvancedFrame.test.tsx | 87 + .../tests/CalendarFrame.test.tsx | 45 +- .../tests/CommonFrame.test.tsx | 143 + .../tests/CurrentCalendarFrame.test.tsx | 37 +- .../tests/CustomFrame.test.tsx | 432 ++ .../tests/DateFilterLabel.test.tsx | 2481 +++++++++- .../tests/DateFilterLabelMotion.test.tsx | 167 + .../tests/limitFields.test.tsx | 67 + .../controls/DateFilterControl/types.ts | 36 +- .../DateFilterControl/useDateFilterLimits.tsx | 600 +++ .../utils/useDatePickerInAdhocFilter.tsx | 3 + .../utils/useDatePickerInAdhocfilter.test.ts | 82 + .../src/explore/components/controls/index.ts | 3 +- .../src/explore/reducers/exploreReducer.ts | 105 + superset-frontend/src/explore/types.ts | 12 + .../ChartVersionPreview.test.tsx | 27 + .../TimeFilterPlugin.timeRangeLimits.test.tsx | 556 +++ .../components/Time/TimeFilterPlugin.tsx | 168 +- .../filters/components/Time/transformProps.ts | 2 + .../src/filters/components/Time/types.ts | 4 + .../src/middleware/asyncEvent.test.ts | 83 + .../src/middleware/asyncEvent.ts | 13 +- .../src/middleware/asyncQueryError.ts | 44 +- .../TimeRangeLimits/TimeRangeLimits.test.tsx | 469 ++ .../src/pages/TimeRangeLimits/index.tsx | 233 + .../src/utils/timeRangeLimits/chartRanges.ts | 230 + .../timeRangeLimits/dateRangeParity.test.ts | 109 + .../src/utils/timeRangeLimits/editorRules.ts | 388 ++ .../src/utils/timeRangeLimits/index.ts | 22 + .../src/utils/timeRangeLimits/requests.ts | 88 + .../src/utils/timeRangeLimits/rules.ts | 720 +++ .../timeRangeLimits/timeRangeLimits.test.ts | 1935 ++++++++ superset-frontend/src/views/routePaths.ts | 1 + superset-frontend/src/views/routes.test.tsx | 28 + superset-frontend/src/views/routes.tsx | 14 + superset-frontend/src/views/store.ts | 2 + superset/charts/data/api.py | 4 + superset/charts/schemas.py | 6 + superset/commands/chart/warm_up_cache.py | 51 +- superset/commands/dashboard/copy.py | 23 + superset/commands/dashboard/create.py | 6 + superset/commands/dashboard/importers/v0.py | 192 +- .../dashboard/importers/v1/__init__.py | 4 + .../commands/dashboard/restore_version.py | 12 + .../commands/dashboard/time_range_limits.py | 77 + superset/commands/dashboard/update.py | 23 + superset/commands/dataset/importers/v0.py | 116 +- .../commands/dataset/importers/v1/__init__.py | 4 + superset/commands/dataset/restore_version.py | 7 + superset/commands/dataset/update.py | 22 + superset/commands/exceptions.py | 6 + superset/commands/explore/get.py | 19 +- superset/commands/importers/v1/__init__.py | 17 +- superset/commands/report/exceptions.py | 24 + superset/commands/report/execute.py | 351 +- superset/commands/version_restore.py | 12 + superset/common/query_context.py | 11 + superset/common/query_context_processor.py | 186 +- superset/common/query_serialization.py | 16 +- superset/common/utils/time_range_utils.py | 101 +- superset/config.py | 12 + superset/constants.py | 3 + superset/daos/dashboard.py | 25 +- superset/dashboards/api.py | 288 +- superset/dashboards/schemas.py | 107 +- superset/datasets/api.py | 168 +- superset/datasets/schemas.py | 74 +- superset/datasource/api.py | 30 +- superset/errors.py | 5 + superset/explore/schemas.py | 10 + superset/initialization/__init__.py | 27 + superset/mcp_service/chart/compile.py | 15 + superset/mcp_service/chart/preview_utils.py | 6 + .../mcp_service/chart/response_preflight.py | 28 + .../mcp_service/chart/tool/generate_chart.py | 2 + .../mcp_service/chart/tool/get_chart_data.py | 29 +- .../chart/tool/get_chart_preview.py | 17 +- .../mcp_service/chart/tool/get_chart_sql.py | 21 +- .../mcp_service/chart/tool/update_chart.py | 2 + .../chart/tool/update_chart_preview.py | 2 + .../dashboard/tool/duplicate_dashboard.py | 33 +- .../dashboard/tool/generate_dashboard.py | 13 + .../dashboard/tool/get_dashboard_data.py | 5 +- .../dashboard/tool/governance_utils.py | 40 +- .../dashboard/tool/update_dashboard.py | 25 +- .../mcp_service/dataset/tool/query_dataset.py | 46 +- .../semantic_layer/tool/get_table.py | 15 +- superset/models/helpers.py | 181 +- superset/models/slice.py | 13 +- superset/security/manager.py | 1 + superset/tasks/async_queries.py | 51 +- superset/time_range_limits/__init__.py | 16 + superset/time_range_limits/api.py | 383 ++ superset/time_range_limits/audit.py | 174 + superset/time_range_limits/enforcement.py | 2211 +++++++++ superset/time_range_limits/notice.py | 159 + superset/time_range_limits/policy.py | 1402 ++++++ superset/time_range_limits/save_check.py | 430 ++ superset/time_range_limits/schemas.py | 341 ++ superset/time_range_limits/telemetry.py | 380 ++ superset/time_range_limits/views.py | 37 + superset/translations/messages.pot | 541 ++- superset/utils/error_sanitization.py | 31 +- superset/versioning/api_helpers.py | 26 +- superset/views/base_api.py | 24 + superset/views/datasource/utils.py | 13 + superset/views/datasource/views.py | 38 +- tests/fixtures/time_range_enforcement.py | 185 + tests/fixtures/time_range_policy.py | 29 + tests/fixtures/time_range_query_paths.py | 216 + tests/fixtures/time_range_request_paths.py | 76 + tests/fixtures/time_range_save_check.py | 214 + tests/fixtures/time_range_semantic_views.py | 145 + tests/fixtures/time_range_telemetry.py | 24 + tests/integration_tests/conftest.py | 163 + .../dashboards/filter_state/api_tests.py | 830 ++-- .../reports/time_range_limits_tests.py | 596 +++ .../time_range_limits_notice_tests.py | 500 ++ .../time_range_limits_page_tests.py | 152 + .../time_range_limits_semantic_views_tests.py | 238 + .../time_range_limits_tests.py | 614 +++ .../time_range_limits_utils.py | 98 + .../time_range_limits_write_utils.py | 59 + .../time_range_limits_writes_tests.py | 1462 ++++++ .../commands/importers/v1/import_test.py | 154 + .../commands/dashboard/create_test.py | 47 + .../commands/dataset/update_test.py | 144 + .../commands/importers/v1/assets_test.py | 146 + .../commands/report/time_range_limits_test.py | 2351 ++++++++++ .../test_base_restore_version_command.py | 99 + .../common/test_query_context_processor.py | 346 ++ .../common/test_time_range_utils.py | 215 + tests/unit_tests/conftest.py | 141 + tests/unit_tests/dashboards/api_test.py | 789 ++++ .../commands/importers/v1/import_test.py | 388 ++ tests/unit_tests/dashboards/dao_tests.py | 117 + tests/unit_tests/dashboards/schema_test.py | 280 ++ .../commands/importers/v1/import_test.py | 99 + tests/unit_tests/datasets/api_tests.py | 592 +++ .../commands/importers/v1/import_test.py | 342 ++ .../partition_mapping_preview_test.py | 44 + tests/unit_tests/datasets/schema_tests.py | 228 + .../chart/tool/test_generate_chart.py | 65 + .../chart/tool/test_get_chart_data.py | 147 + .../chart/tool/test_get_chart_preview.py | 67 + .../chart/tool/test_get_chart_sql.py | 77 + .../chart/tool/test_update_chart.py | 60 + .../chart/tool/test_update_chart_preview.py | 82 + .../tool/test_dashboard_generation.py | 68 + .../tool/test_duplicate_dashboard.py | 55 + .../dashboard/tool/test_get_dashboard_data.py | 21 + .../dashboard/tool/test_update_dashboard.py | 261 ++ .../dataset/tool/test_query_dataset.py | 137 + .../semantic_layer/tool/test_get_table.py | 34 + tests/unit_tests/models/helpers_test.py | 62 + .../semantic_layers/time_range_limits_test.py | 1097 +++++ .../utils/test_date_range_parity.py | 105 + .../utils/test_date_range_query_paths.py | 1346 ++++++ .../utils/test_date_range_request_paths.py | 563 +++ .../utils/test_error_sanitization.py | 63 + .../unit_tests/utils/test_time_range_audit.py | 567 +++ .../utils/test_time_range_editor_notice.py | 462 ++ .../utils/test_time_range_enforcement.py | 4096 +++++++++++++++++ .../utils/test_time_range_limits.py | 4014 ++++++++++++++++ .../utils/test_time_range_save_check.py | 1028 +++++ .../utils/test_time_range_telemetry.py | 801 ++++ .../test_restore_version_endpoint_limits.py | 85 + .../unit_tests/views/datasource/views_test.py | 395 ++ 290 files changed, 76273 insertions(+), 1245 deletions(-) create mode 100644 docs/admin_docs/configuration/time-range-limits.mdx create mode 100644 superset-frontend/spec/fixtures/dateRangeParity.json create mode 100644 superset-frontend/src/components/Datasource/DatasourceModal/DatasourceModal.timeRangeLimits.test.tsx create mode 100644 superset-frontend/src/components/TimeRangeLimits/TimeRangeLimitsSection.test.tsx create mode 100644 superset-frontend/src/components/TimeRangeLimits/TimeRangeLimitsSection.tsx create mode 100644 superset-frontend/src/components/TimeRangeLimits/limitAlertIcon.test.tsx create mode 100644 superset-frontend/src/components/TimeRangeLimits/limitAlertIcon.tsx create mode 100644 superset-frontend/src/components/TimeRangeLimits/policyDraft.ts create mode 100644 superset-frontend/src/components/TimeRangeLimits/useSaveCheck.test.ts create mode 100644 superset-frontend/src/components/TimeRangeLimits/useSaveCheck.ts create mode 100644 superset-frontend/src/components/TimeRangeLimits/useTimeRangeLimits.ts create mode 100644 superset-frontend/src/dashboard/actions/timeRangeLimits.test.ts create mode 100644 superset-frontend/src/dashboard/actions/timeRangeLimits.ts create mode 100644 superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.editorNotice.test.tsx create mode 100644 superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.timeRangeLimits.test.tsx create mode 100644 superset-frontend/src/dashboard/components/DashboardBuilder/TimeRangeLimitsAlert.tsx create mode 100644 superset-frontend/src/dashboard/components/DashboardBuilder/TimeRangeLimitsEditorNotice.tsx create mode 100644 superset-frontend/src/dashboard/components/PropertiesModal/PropertiesModal.timeRangeLimits.test.tsx create mode 100644 superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.timeRangeLimits.test.tsx create mode 100644 superset-frontend/src/dashboard/components/gridComponents/Chart/HeldChartPlaceholder.tsx create mode 100644 superset-frontend/src/dashboard/components/gridComponents/Chart/useChartTimeRangeHold.ts create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterBar.timeRangeLimits.test.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.timeRangeLimits.test.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.timeRangeLimits.defaultValue.test.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.timeRangeLimits.optionQueryHold.test.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.timeRangeLimits.preview.test.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.timeRangeLimits.previewReset.test.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.timeRangeLimits.test.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.timeRangeLimits.testHelpers.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/TimeRangeLimitsAlerts.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/useFilterTimeRangeLimits.tsx create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/useDashboardTimeRangeLimits.test.ts create mode 100644 superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/useDashboardTimeRangeLimits.ts create mode 100644 superset-frontend/src/dashboard/reducers/timeRangeLimits.test.ts create mode 100644 superset-frontend/src/dashboard/reducers/timeRangeLimits.ts create mode 100644 superset-frontend/src/explore/actions/timeRangeLimitsActions.test.ts create mode 100644 superset-frontend/src/explore/actions/timeRangeLimitsActions.ts create mode 100644 superset-frontend/src/explore/components/ExploreChartPanel/useTimeRangeAlert.tsx create mode 100644 superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.timeRangeLimits.chartArea.test.tsx create mode 100644 superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.timeRangeLimits.focus.test.tsx create mode 100644 superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.timeRangeLimits.test.tsx create mode 100644 superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.timeRangeLimits.testHelpers.tsx create mode 100644 superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.timeRangeLimits.timeCompareOptions.test.tsx create mode 100644 superset-frontend/src/explore/components/ExploreViewContainer/useExploreTimeRangeCheck.ts create mode 100644 superset-frontend/src/explore/components/ExploreViewContainer/useExploreTimeRangeLimits.tsx create mode 100644 superset-frontend/src/explore/components/controls/DateFilterControl/ExploreTimeRangeLimitsContext.test.tsx create mode 100644 superset-frontend/src/explore/components/controls/DateFilterControl/ExploreTimeRangeLimitsContext.tsx create mode 100644 superset-frontend/src/explore/components/controls/DateFilterControl/components/limitFields.tsx create mode 100644 superset-frontend/src/explore/components/controls/DateFilterControl/tests/DateFilterLabelMotion.test.tsx create mode 100644 superset-frontend/src/explore/components/controls/DateFilterControl/tests/limitFields.test.tsx create mode 100644 superset-frontend/src/explore/components/controls/DateFilterControl/useDateFilterLimits.tsx create mode 100644 superset-frontend/src/filters/components/Time/TimeFilterPlugin.timeRangeLimits.test.tsx create mode 100644 superset-frontend/src/pages/TimeRangeLimits/TimeRangeLimits.test.tsx create mode 100644 superset-frontend/src/pages/TimeRangeLimits/index.tsx create mode 100644 superset-frontend/src/utils/timeRangeLimits/chartRanges.ts create mode 100644 superset-frontend/src/utils/timeRangeLimits/dateRangeParity.test.ts create mode 100644 superset-frontend/src/utils/timeRangeLimits/editorRules.ts create mode 100644 superset-frontend/src/utils/timeRangeLimits/index.ts create mode 100644 superset-frontend/src/utils/timeRangeLimits/requests.ts create mode 100644 superset-frontend/src/utils/timeRangeLimits/rules.ts create mode 100644 superset-frontend/src/utils/timeRangeLimits/timeRangeLimits.test.ts create mode 100644 superset/commands/dashboard/time_range_limits.py create mode 100644 superset/time_range_limits/__init__.py create mode 100644 superset/time_range_limits/api.py create mode 100644 superset/time_range_limits/audit.py create mode 100644 superset/time_range_limits/enforcement.py create mode 100644 superset/time_range_limits/notice.py create mode 100644 superset/time_range_limits/policy.py create mode 100644 superset/time_range_limits/save_check.py create mode 100644 superset/time_range_limits/schemas.py create mode 100644 superset/time_range_limits/telemetry.py create mode 100644 superset/time_range_limits/views.py create mode 100644 tests/fixtures/time_range_enforcement.py create mode 100644 tests/fixtures/time_range_policy.py create mode 100644 tests/fixtures/time_range_query_paths.py create mode 100644 tests/fixtures/time_range_request_paths.py create mode 100644 tests/fixtures/time_range_save_check.py create mode 100644 tests/fixtures/time_range_semantic_views.py create mode 100644 tests/fixtures/time_range_telemetry.py create mode 100644 tests/integration_tests/reports/time_range_limits_tests.py create mode 100644 tests/integration_tests/time_range_limits_notice_tests.py create mode 100644 tests/integration_tests/time_range_limits_page_tests.py create mode 100644 tests/integration_tests/time_range_limits_semantic_views_tests.py create mode 100644 tests/integration_tests/time_range_limits_tests.py create mode 100644 tests/integration_tests/time_range_limits_utils.py create mode 100644 tests/integration_tests/time_range_limits_write_utils.py create mode 100644 tests/integration_tests/time_range_limits_writes_tests.py create mode 100644 tests/unit_tests/commands/report/time_range_limits_test.py create mode 100644 tests/unit_tests/dashboards/schema_test.py create mode 100644 tests/unit_tests/semantic_layers/time_range_limits_test.py create mode 100644 tests/unit_tests/utils/test_date_range_parity.py create mode 100644 tests/unit_tests/utils/test_date_range_query_paths.py create mode 100644 tests/unit_tests/utils/test_date_range_request_paths.py create mode 100644 tests/unit_tests/utils/test_time_range_audit.py create mode 100644 tests/unit_tests/utils/test_time_range_editor_notice.py create mode 100644 tests/unit_tests/utils/test_time_range_enforcement.py create mode 100644 tests/unit_tests/utils/test_time_range_limits.py create mode 100644 tests/unit_tests/utils/test_time_range_save_check.py create mode 100644 tests/unit_tests/utils/test_time_range_telemetry.py create mode 100644 tests/unit_tests/versioning/test_restore_version_endpoint_limits.py diff --git a/UPDATING.md b/UPDATING.md index e7e6cb0e29dd..c97165f74b15 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -38,6 +38,12 @@ assists people when migrating to a new version. is not cached. Engines whose cancel id only exists once a query is running, such as Trino, still cannot cancel chart-data queries: their tasks stay non-abortable, as before. +- Run `superset init` after upgrading to create the time range limits API and + admin-page permissions. The `TIME_RANGE_LIMITS` feature flag is off by default. + While it is off, supplied `time_range_limits` values are stored unchecked. + Review stored values before enabling it: an invalid applicable policy makes + queries fail closed with HTTP 503. See the + [time range limits configuration](https://superset.apache.org/admin-docs/configuration/time-range-limits). - **Semantic-layer schema readiness and failures.** Both provider schema hooks require keyword-only `readiness: SemanticCredentialReadiness` alongside diff --git a/docs/admin_docs/configuration/time-range-limits.mdx b/docs/admin_docs/configuration/time-range-limits.mdx new file mode 100644 index 000000000000..321c36e6b766 --- /dev/null +++ b/docs/admin_docs/configuration/time-range-limits.mdx @@ -0,0 +1,269 @@ +--- +title: Time range limits +hide_title: true +sidebar_position: 16 +version: 1 +--- + + + +# Time range limits + +Feature flag: `TIME_RANGE_LIMITS` (development, off by default). + +Time range limits cap how wide a time range a chart query may ask for, how far back it may start and how far forward it may end. An admin sets them for the whole deployment; a dataset or a dashboard can only make them stricter. The server checks every chart data query before the data cache, the async task queue and any SQL. + +With the flag off no query is checked and the endpoints below answer 404. Writes aren't checked either: a `time_range_limits` key is stored as Superset stores any other value (see [Writes](#writes-400-and-422)). A value stored that way fails queries closed once the flag is on, see [What fails closed](#what-fails-closed) before turning it on. + +## Turn it on + +In `superset_config.py`, turn on the [feature flag](/admin-docs/configuration/feature-flags) and set the global limits: + +```python +FEATURE_FLAGS = { + "TIME_RANGE_LIMITS": True, +} + +TIME_RANGE_LIMITS_GLOBAL = { + "schema_version": 1, + "max_span": {"quantity": 3, "time_grain": "P1M"}, + "max_look_back": {"quantity": 13, "time_grain": "P1M"}, + "max_look_forward": {"quantity": 0, "time_grain": "P1D"}, +} +``` + +- Every key is required. A rule is `null` (no limit) or an object with `quantity` and `time_grain`. +- `quantity` is a whole number from 1 to 9999 for `max_span`, and from 0 to 9999 for `max_look_back` and `max_look_forward`. +- `time_grain` is a value of the Time Grain list: the builtin grains plus the enabled `TIME_GRAIN_ADDONS`, minus `TIME_GRAIN_DENYLIST`. Only grains with a length count (for example `P1D`, `P1W`, `P1M`, `P3M`, `P1Y`, `PT1H`). +- The global limits are deployment config: changing them needs a restart. + +A bad global (for example `{}`, a missing key, or zero `max_look_back` with zero `max_look_forward`) doesn't stop Superset from starting, but every guarded query fails closed (see [What fails closed](#what-fails-closed)). + +## Levels + +Limits come from up to three levels. For each rule, the strictest level applies. + +1. **Global**: `TIME_RANGE_LIMITS_GLOBAL`. +2. **Dataset**: the dataset's `extra`, under `time_range_limits`, in the same shape. It applies to every chart on the dataset. +3. **Dashboard**: the dashboard's `json_metadata`, under `time_range_limits`. It applies to the dashboard's charts, to a chart loaded with `GET /api/v1/chart//data/?filters_dashboard_id=`, to Samples and Drill to detail opened from the dashboard, and to cache warm-up for that dashboard. + +A dashboard's limits apply when the request names the dashboard (`dashboardId` in the form data) and the user reached the data through it (an embedded guest, or dashboard access only), or when the chart is on the dashboard and the user can open it. + +A chart viewer let in by `ENABLE_VIEWERS` with `VIEWER_PROMISCUOUS_MODE` counts as reaching the data through a dashboard, so a `dashboardId` that isn't a plain integer gets 503 `POLICY_UNAVAILABLE` instead of being ignored; this never loosens the limits. + +## Rules + +| Rule | Check | +|---|---| +| `max_span` | `end <= start + duration` | +| `max_look_back` | `start >= midnight(now) - duration` | +| `max_look_forward` | `end <= midnight(now) + duration + 1 day` | + +The duration is `quantity × time_grain`. Ranges are end-exclusive. For units under a day, the look-back check is `start >= now - duration` and the look-forward check is `end <= now + duration`, with the one-second slack described below. + +- **Day and longer units** are calendar units: the span is counted forward from the start, so `2026-02-28 : 2026-03-31` is longer than 1 month (February 28 plus 1 month is March 28). The look-forward bound starts at midnight, so a range can run to the end of today even when `max_look_forward` is 0. +- **Look-back** of a day or longer counts from the start of today, in the server's time zone. With a 7-day look-back, "Last 7 days" passes, and "Current day" or `today : now` passes a look-back of 0. +- **Units under a day** are elapsed time from `now`, with one second of slack either way. They follow the server's wall clock, as Superset's own date parser does. +- A query on a datasource with a temporal column and no time range is refused while any rule applies (`UNBOUNDED_RANGE`). Charts with no temporal column run as before. +- Time comparison ranges and annotation layer charts are checked as well. A time filter written in custom SQL or a Jinja template that can remove the filter counts as no time range. + +## What users see + +- **Admin page:** admins find a read-only **Time range limits** page under **Settings → Manage**. It shows the global longest range, earliest start and latest end, and a disabled switch that is on when any rule is set, because the global limits come from the deployment configuration. If they can't be loaded it says "Time range limits could not be loaded. Queries are paused to protect the database." with a **Retry** button (one request per click, a 10 second timeout, no automatic retry). +- **Dashboard properties:** dashboard editors get a **Time range limits** section with the global, dashboard and effective value of each rule side by side. A dashboard value can only be stricter than the global one; a looser value shows an error and disables Save. While the editor types, the section lists the charts and time range filter defaults whose own saved range the new value would refuse, with links that open each chart. The list warns and never blocks the save. The section saves through the dashboard limits endpoint when the properties are saved (from the dashboard header, when the dashboard is saved). If the stored value can't be read, the section offers **Retry** and **Remove dashboard limits**, which clears the stored value on save. +- **Editor notice:** when the global limits turn on, or a global rule gets stricter, each dashboard editor sees a one-time info alert at the top of the dashboard ("Your admin turned on time range limits: …" or "Your admin changed time range limits: …") with a **Got it** button. Editors are the users whose own user, roles or groups are listed as the dashboard's editors, plus any editors `EXTRA_EDITORS_RESOLVER` adds; admins see it only when one of these lists them. Viewers, embedded guests and standalone views (embedded dashboards, reports) don't see it, and loosening the limits shows nothing. **Got it** hides it and stores the global limits the editor acknowledged as one `key_value` row per editor (resource `app`, keyed by a UUID derived from the user id, no expiry), so the notice comes back only when the limits get stricter than that. The row names no user, so deleting the user still works and leaves the row behind; deleting a row only shows the notice to that editor again. +- **Dataset editor:** the **Settings** tab has the same section for the dataset, below the Basic and Advanced cards. It saves inside the dataset's `extra` with the rest of the dataset, and the **Extra** field doesn't show the `time_range_limits` key. A dataset with no time column shows its stored value, disabled, since there is nothing to limit. + +## Guarded paths + +- `POST /api/v1/chart/data` and `GET /api/v1/chart//data/` (sync and async) +- `POST /api/v1/query/` and `POST /api/v1/datasource///query` +- Samples and Drill to detail (`POST /datasource/samples`) +- The column values list (`GET /api/v1/datasource///column//values/`, for datasets and semantic views), checked as a query with no time range +- Cache warm-up (`PUT /api/v1/chart/warm_up_cache`, `PUT /api/v1/dataset/warm_up_cache`); a refused chart is reported in its `errors` +- The MCP chart, dataset, dashboard and semantic layer data tools, which return the error in their own error shape +- Scheduled reports, before anything renders (see [Scheduled reports](#scheduled-reports)) +- `GET /api/v1/explore/` returns the global and dataset limits in `time_range_limits` + +### Scheduled reports + +When a report is sent, every chart it would render is checked first: the saved chart of a chart report, or each chart on the tabs a dashboard report renders, with the native filters its page renders. If any chart breaks the limits, the report sends nothing and runs no query. The execution log records it as an error with the reason (each failing chart by name for a dashboard report), and the report's editors get the same reason in Superset's usual error email. A limit refusal isn't retried, even with report retries on; when the limits can't be loaded, the failure is retried like any other. A chart that can't be checked fails the report while any limit could apply to it. Alerts aren't checked before they run. + +The report's other errors (a deleted target, a missing executor, an executor that can't open the dashboard, recipients outside the recipient policy) come first, with their own messages. + +### Async queries + +With `GLOBAL_ASYNC_QUERIES`, a refused request answers 400 before any task is queued. The task checks the query again before its SQL runs, so limits tightened while it was queued fail the task without SQL. The task then carries only a reason code; the browser sends the request again and shows the full 400 error. Requests for the same query share a task only when the same dashboard's limits apply to them. + +### Semantic views + +With `SEMANTIC_LAYERS` on, charts on semantic views are checked on the same paths. A semantic view has no dataset level, so the global and dashboard limits apply. The check reads the ranges the semantic layer sends to the view's provider: + +- The time axis is the query's time column (`granularity`), else its first temporal dimension, else the first temporal dimension a time range filter names. The query's time range bounds the axis only when it has both a start and an end; a one-sided range there counts as no time range. +- A time range filter on another temporal dimension bounds that dimension. +- Time comparisons are checked as the semantic layer shifts them. +- A query with no time range, including "No filter", on a view with a temporal dimension is refused (`UNBOUNDED_RANGE`). A view with no temporal dimension runs as before. +- Native Select filter value lists on a semantic view may run with no time range, as on datasets. A filter target counts only for the datasource type it names; a target with no type is a dataset target. +- A saved chart on a semantic view with a temporal dimension is checked from its saved query context. One saved without a query context can't be checked: the dashboard's chart list shows it as not checked, and a report on it fails while a limit could apply. A chart on a view with no temporal dimension has no limits, with or without a saved query context. + +## Custom time range calendars + +The Start and End calendars grey out only days outside the absolute look-back and look-forward limits. Days inside those limits remain selectable regardless of the other endpoint, including when the draft would exceed the longest range or put Start on or after End. This lets you move an endpoint backward or forward and then repair the other endpoint. Invalid drafts show the field or range error and keep Apply disabled until the server-backed check accepts the range. + +## API + +All endpoints answer 404 with the flag off. After an upgrade, run `superset init` so the new `TimeRangeLimits` permission and the admin-only **Settings → Manage → Time range limits** menu item (`menu_access` on `TimeRangeLimits`) exist. The notice endpoint needs no `superset init` of its own: it uses the dashboard `can_read` permission. + +| Endpoint | What | Who | +|---|---|---| +| `GET /api/v1/time_range_limits/` | The global limits and the time grains a rule may use | Admins | +| `POST /api/v1/time_range_limits/chart_ranges/` | The ranges each chart data request (at most 50) will be checked against, without running it | Anyone who can read charts | +| `GET /api/v1/dashboard//time_range_limits/` | The global, dashboard and dataset limits of a dashboard, what they enforce together, and the current user's editor notice (`editor_notice`: `turned_on`, `tightened` or `null`) | Anyone who can read the dashboard | +| `PUT /api/v1/dashboard//time_range_limits/` | Replaces the dashboard's limits, or removes them with `"policy": null` | Dashboard editors and admins | +| `GET /api/v1/dashboard//time_range_limits/charts/` | The charts and time range filter defaults a proposed policy would refuse | Dashboard editors and admins | +| `GET /api/v1/dataset//time_range_limits/` | The global and dataset limits of a dataset | Anyone who can read the dataset | +| `GET /api/v1/dataset//time_range_limits/charts/` | The charts a proposed dataset policy would refuse. Only the first 50 charts of the dataset, by ID, are checked; `truncated` is `true` when it has more | Dataset editors and admins | +| `PUT /api/v1/time_range_limits/notice/` | Acknowledges the editor notice: stores the current global limits as the ones the user saw. The body is ignored | Any logged-in user who can read dashboards, for their own notice only; guests and anonymous users get 403 | + +The dashboard limits response includes dataset entries only for datasets the caller can read. Global and dashboard policies remain visible to dashboard readers even when a dataset entry is omitted. Embedded guests receive policies for member datasets authorized by their dashboard token and its dataset allowlist. Datasets the caller cannot read are omitted. Charts on omitted datasets are not held client-side because of that omission: their requests reach the standard server access check, and server-side limits still apply. Accessible datasets without a policy are also omitted and their charts remain unaffected when no global or dashboard limit applies. + +An invalid dataset policy does not fail the entire dashboard response. Its dataset entry has `policy: null` and an `error` object with `message`, `error_type`, and `extra` (`reason_code: "POLICY_UNAVAILABLE"`, the source, field, and dataset ID). Other dataset entries retain their policies. Only charts using the unavailable dataset remain held; other charts can run after their own checks pass. The raw stored `extra.value` is returned only to that dataset's editor on GET and is omitted from PUT responses. Invalid global or dashboard policies still fail the response with HTTP 503. + +Dashboard and dataset chart save checks evaluate at most 50 readable charts and return `truncated: true` when more charts are available. An unreadable chart's dataset policy does not affect the dashboard chart check. + +The `time_range_limits` key can also be written through the usual dashboard create, update, copy and import, the dataset update and import, the legacy datasource save, and the MCP `update_dashboard` and `generate_dashboard` tools (see [Writes](#writes-400-and-422)). + +## Errors + +### Query refused (400) + +A query that breaks the limits answers 400 with `error_type` `TIME_RANGE_LIMIT_ERROR` ([Issue 1042](/user-docs/using-superset/issue-codes#issue-1042)) and runs no SQL. `extra` has: + +- `reason_code`: `MAX_SPAN_EXCEEDED`, `MAX_LOOK_BACK_EXCEEDED`, `MAX_LOOK_FORWARD_EXCEEDED`, `UNBOUNDED_RANGE` (no start or no end) or `NO_VALID_RANGE` (the limits leave no valid range). +- `rule` and `limit`: the rule that failed and its `quantity` / `time_grain`. +- `source`: the level that set it (`global`, `dataset` or `dashboard`). +- `actual`: the `start` and `end` of the range, in the server's UTC offset. +- `timezone`: the server's time zone (see [Time zone](#time-zone)). +- `dashboard_id`: the dashboard whose limits applied, if any. +- `time_offset`: the time comparison offset, for a comparison range. +- `filter_id`: always `null`. + +Embedded (guest token) viewers get the same error, with these `extra` keys and `issue_codes` only. Anything else in `extra` is dropped, as for every guest error. + +### Limits unavailable (503) + +When the limits that apply can't be read, the query fails closed with 503 and `reason_code` `POLICY_UNAVAILABLE`. `extra` names the `source` level, the `field` that is wrong, and the `dataset_id` for a dataset. It never holds the stored value, except on the endpoints that editors use to fix it: the global limits GET (admins only), and the dashboard and dataset limits GET and save checks (`…/time_range_limits/charts/`). There `extra` also holds the stored `value` of the wrong `field`, but only for an editor of the level named in `source`: an admin for `global`, an editor of the dashboard for `dashboard`, and an editor of the `dataset_id` dataset for `dataset`. Embedded (guest token) viewers get only `reason_code` and `source` in `extra`. + +### Writes (400 and 422) + +These checks run while the flag is on. + +- Dashboard create, update and copy, the dashboard limits PUT and the legacy datasource save answer **400** for a value that isn't a valid policy (bad shape, unknown `schema_version`, a unit that isn't eligible) and **422** for a valid policy that is looser than the global one. +- The dataset update and the dashboard and dataset imports (V0 and V1, including asset bundles) answer **422** for both, as Superset does for any invalid dataset update or import file. Nothing is written. +- A rule that is unchanged from the stored value is accepted, even when a later global change made it looser, so unrelated edits still save. For an import, the stored value is the one of the row the import overwrites or restores (matched by UUID, a soft-deleted row included, by slug for a dashboard, or by database, catalog, schema and table for a dataset). +- A dashboard update merges `json_metadata` into the stored one: a body without the `time_range_limits` key keeps the stored limits and isn't checked, `"time_range_limits": null` removes them, and a value is checked. +- The MCP `update_dashboard` and `generate_dashboard` tools run the same check on `json_metadata_overrides` and write nothing when it fails. `update_dashboard` returns a tool error of type `TimeRangeLimitsInvalid`, or `TimeRangeLimitsUnavailable` under a bad global; `generate_dashboard` returns the message in its usual `error` field. +- The body's `message` maps each field path to its messages, for example `{"message": {"json_metadata.time_range_limits.max_span.quantity": ["Dashboard maximum span must be 12 weeks or less."]}}`. The path starts with `json_metadata.` for dashboard writes, `policy.` for the dashboard limits PUT, and `extra.` for the dataset update and the legacy datasource save. +- A write under a bad global answers 503. +- With the flag off none of these checks run: the value is stored as sent and no 400 or 422 comes from it. +- In the dashboard limits GET, `needs_review` lists the dashboard rules a tighter global overrides, with the reason `LOOSER_THAN_GLOBAL` or `UNIT_MISMATCH`. + +### Restores + +- While the flag is on, restoring a dashboard or dataset **version** (`POST /api/v1/{dashboard,dataset}//versions//restore`) checks the restored limits like a write: a value looser than the global one answers **422** with the field-keyed `message` and the restore is rolled back, unless it equals the live value; a bad global answers 503. +- Restoring a **soft-deleted** dashboard or dataset (`POST /api/v1/{dashboard,dataset}//restore`) never changes its stored limits, so it is always accepted. A stale value looser than the global one stays, as for any unchanged value, and queries still get the strictest level. + +## Logs and metrics + +### Metrics + +Through Superset's `STATS_LOGGER`: + +| Key | When | +|---|---| +| `time_range_limit.rejected...` | Each 400 | +| `time_range_limit.unavailable..` | Each 503 | +| `time_range_limit.unchecked.report` | Each chart a report fails because it can't be checked | + +- `path`: `chart_data`, `samples`, `column_values`, `datasource_query`, `query_api`, `warm_up`, `explore_load`, `policy_api`, `policy_write`, `async_worker`, `mcp`, `report` or `other`. `policy_api` is the limits endpoints (the global GET, the dashboard GET / PUT / charts, the dataset GET / charts, `chart_ranges`); `policy_write` is the dashboard create, update, copy and import, the dataset update and import, the legacy datasource save, the asset bundle import and both version restores. The MCP dashboard writes count under `mcp`. +- `rule`: `max_span`, `max_look_back`, `max_look_forward`, `unbounded`, `no_valid_range` or `other`. +- `source`: `global`, `dataset`, `dashboard`; `none` for `unbounded` and `no_valid_range`; `unknown` on a 503 that names no level. + +### Log lines + +Logger `superset.time_range_limits`, one `key=value` line per refusal (the same fields are in `record.time_range_limit` for JSON formatters): + +``` +Time range rejected: path=chart_data reason_code=MAX_SPAN_EXCEEDED rule=max_span source=dashboard dashboard_id=42 chart_id=12 dataset_id=7 user_id=3 guest=false limit=9xP1W actual_start=2026-06-22T00:00:00+00:00 actual_end=2026-09-14T00:00:00+00:00 comparison=false +``` + +- INFO `Time range rejected: …` for a 400; WARNING `Time range limits unavailable: …` for a 503; WARNING `Time range limits not checked: …` for a report chart that can't be checked. A report refusal counts under `path=report`. +- A missing value is `none`. The line never holds the error message, the stored policy, SQL, form data or Jinja. + +At startup, with the flag on, Superset logs the global policy once (`Time range limits: global policy …`), or that there is none, or an ERROR when `TIME_RANGE_LIMITS_GLOBAL` is not valid. + +### Audit rows + +Each committed change of a dashboard's or dataset's limits writes one row to the `logs` table, in the same transaction, from every write path (API, imports, version restore, MCP), while the flag is on. The rows don't depend on version history, which can be turned off or pruned by retention: + +- `action` = `time_range_limits.change`, `user_id`, `dashboard_id` (for a dashboard), `dttm`. +- `json` = `{"level": "dashboard"|"dataset", "dashboard_id"|"dataset_id": , "old": |null, "new": |null, "path": |null}`, plus `"old_invalid": true` when the old value failed closed. + +Find them under **Settings → Security → Action Log** (filter Action = `time_range_limits.change`), or with SQL: + +```sql +SELECT dttm, user_id, dashboard_id, json +FROM logs +WHERE action = 'time_range_limits.change' +ORDER BY dttm DESC; +``` + +If a row can't be built, the save still commits and the server logs an ERROR `Time range limits: audit row for not written`. Alert on it. + +## What fails closed + +These make every query that uses them fail with 503 `POLICY_UNAVAILABLE`: + +- A stored dataset `extra` or dashboard `json_metadata` that mentions `time_range_limits` but isn't valid JSON or isn't an object. +- A stored policy with a missing or unknown key, another `schema_version`, or a unit that isn't eligible (including one later added to `TIME_GRAIN_DENYLIST`). +- A bad or empty global, or zero `max_look_back` with zero `max_look_forward`. + +These don't fail closed: `null`, a blank value, no `time_range_limits` key, bad JSON that doesn't mention the key, and a stored value looser than the global one (the stricter value applies). + +Before turning the flag on, you can look for stored policies, for example: + +```sql +SELECT id, table_name, extra FROM tables WHERE extra LIKE '%time_range_limits%'; +SELECT id, dashboard_title, json_metadata FROM dashboards +WHERE json_metadata LIKE '%time_range_limits%'; +``` + +## Time zone + +Set the `TZ` environment variable of the web and worker processes to an IANA name, ideally `UTC`. The error's `timezone` is `TZ`, or the local time zone abbreviation (for example `CEST`) when `TZ` isn't set. See [Timezones](/admin-docs/configuration/timezones). + +On engines that group time inline, a series-ranking subquery does not inherit +the outer time window. Guarded charts with a series limit need an independent +temporal filter that bounds the ranking query; otherwise the range check refuses +the request as unbounded. + +Dashboard limits also cover configured child layers of a deck.gl multilayer +chart. The parent must belong to the dashboard, list the requested child, and +the child must use the requested datasource. diff --git a/docs/docs/using-superset/issue-codes.mdx b/docs/docs/using-superset/issue-codes.mdx index 59b7fdaa2246..bed093ef004a 100644 --- a/docs/docs/using-superset/issue-codes.mdx +++ b/docs/docs/using-superset/issue-codes.mdx @@ -332,3 +332,20 @@ The database was deleted. ``` The operation failed because the database referenced no longer exists. Please reach out to your administrator for further assistance. + +## Issue 1042 + +``` +The time range is outside the allowed limits, or the limits can't be verified. +``` + +The chart or query asks for a time range that the time range limits don't allow, or the limits that apply to it can't be read. The error's `reason_code` says which: + +- `MAX_SPAN_EXCEEDED`, `MAX_LOOK_BACK_EXCEEDED`, `MAX_LOOK_FORWARD_EXCEEDED`: the range is too long, starts too early or ends too late. Pick a time range inside the limits named in the error message, or ask an editor or admin to change them. +- `UNBOUNDED_RANGE`: the range has no start or no end. Choose both. On a dashboard, ask a dashboard editor to add or scope a time range filter. +- `NO_VALID_RANGE`: the limits leave no valid time range at all. Only an editor or admin can fix them. +- `POLICY_UNAVAILABLE` (HTTP 503): the global, dataset or dashboard setting isn't valid, so the query is blocked until it is fixed. Retry the request, or ask an editor or admin to check the time range limits. + +Admins: see [Time range limits](/admin-docs/configuration/time-range-limits) for the setup, the error fields and what fails closed. + +The error's `actual` range uses the server's UTC offset, and its `timezone` is the server's `TZ` environment variable, or the local time zone abbreviation (for example `CEST`) when `TZ` isn't set. Set `TZ` to an IANA name (for example `America/Sao_Paulo`) to get that name instead. diff --git a/docs/static/feature-flags.json b/docs/static/feature-flags.json index fd2f9ce7fadd..7487b4eae68a 100644 --- a/docs/static/feature-flags.json +++ b/docs/static/feature-flags.json @@ -117,6 +117,13 @@ "default": false, "lifecycle": "development", "description": "Enable Table V2 time comparison feature" + }, + { + "name": "TIME_RANGE_LIMITS", + "default": false, + "lifecycle": "development", + "description": "Enforce the time range limits (see TIME_RANGE_LIMITS_GLOBAL) on chart data queries", + "docs": "https://superset.apache.org/admin-docs/configuration/time-range-limits" } ], "testing": [ diff --git a/docs/static/resources/openapi.json b/docs/static/resources/openapi.json index 0fb99404ba1c..072422949726 100644 --- a/docs/static/resources/openapi.json +++ b/docs/static/resources/openapi.json @@ -120,6 +120,7 @@ "ADHOC_SUBQUERY_NOT_ALLOWED_ERROR", "INVALID_SQL_ERROR", "RESULT_TOO_LARGE_ERROR", + "TIME_RANGE_LIMIT_ERROR", "GENERIC_COMMAND_ERROR", "GENERIC_BACKEND_ERROR", "INVALID_PAYLOAD_FORMAT_ERROR", @@ -188,6 +189,38 @@ } }, "description": "Fatal error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "properties": { + "errors": { + "items": { + "properties": { + "error_type": { + "type": "string" + }, + "extra": { + "type": "object" + }, + "level": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "The time range limits could not be loaded" } }, "schemas": { @@ -993,6 +1026,13 @@ "description": "The ID of the chart the status belongs to", "type": "integer" }, + "errors": { + "description": "SIP-40 errors, when the chart breaks the time range limits", + "items": { + "type": "object" + }, + "type": "array" + }, "viz_error": { "description": "Error that occurred when warming cache for chart", "type": "string" @@ -3088,6 +3128,131 @@ }, "type": "object" }, + "ChartRange": { + "properties": { + "since": { + "description": "Naive ISO.", + "nullable": true, + "type": "string" + }, + "source": { + "description": "Where the range comes from; null when unknown.", + "items": { + "$ref": "#/components/schemas/ChartRangeSource" + }, + "nullable": true, + "type": "array" + }, + "time_offset": { + "description": "The time comparison, if any.", + "nullable": true, + "type": "string" + }, + "until": { + "description": "Naive ISO.", + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, + "ChartRangeSource": { + "properties": { + "index": { + "description": "The filter's index in the query's filters, or the time comparison's index; null otherwise.", + "nullable": true, + "type": "integer" + }, + "kind": { + "description": "time_range, adhoc_filter, granularity, time_comparison or time_shift.", + "type": "string" + }, + "query_index": { + "description": "The query it is in.", + "type": "integer" + } + }, + "type": "object" + }, + "ChartRangesChart": { + "properties": { + "dashboard_id": { + "description": "The dashboard whose limits apply, if any.", + "nullable": true, + "type": "integer" + }, + "dataset_id": { + "nullable": true, + "type": "integer" + }, + "error": { + "$ref": "#/components/schemas/ChartRangesError" + }, + "key": { + "type": "string" + }, + "ranges": { + "items": { + "$ref": "#/components/schemas/ChartRange" + }, + "type": "array" + }, + "status": { + "description": "ok or error.", + "type": "string" + } + }, + "type": "object" + }, + "ChartRangesError": { + "properties": { + "body": { + "description": "The chart route's JSON body.", + "type": "object" + }, + "status": { + "description": "The chart route's status.", + "type": "integer" + } + }, + "type": "object" + }, + "ChartRangesRequestSchema": { + "properties": { + "charts": { + "description": "One entry per chart, at most 50: a key with its `query_context`.", + "items": {}, + "maxItems": 50, + "type": "array" + } + }, + "required": [ + "charts" + ], + "type": "object" + }, + "ChartRangesResponseSchema": { + "properties": { + "charts": { + "items": { + "$ref": "#/components/schemas/ChartRangesChart" + }, + "type": "array" + }, + "now": { + "description": "The server clock, naive ISO.", + "type": "string" + }, + "timezone": { + "type": "string" + }, + "today": { + "description": "Midnight of now, naive ISO.", + "type": "string" + } + }, + "type": "object" + }, "ChartRestApi.get": { "properties": { "id": { @@ -5018,6 +5183,23 @@ }, "type": "object" }, + "DashboardTimeRangeLimitsPutSchema": { + "properties": { + "global_revision": { + "description": "Accepted and ignored: the global has no revisions.", + "nullable": true, + "type": "integer" + }, + "policy": { + "description": "The dashboard's time range limits, or null to remove them.", + "nullable": true + } + }, + "required": [ + "policy" + ], + "type": "object" + }, "Database": { "properties": { "allow_multi_catalog": { @@ -6289,6 +6471,13 @@ "description": "The ID of the chart the status belongs to", "type": "integer" }, + "errors": { + "description": "SIP-40 errors, when the chart breaks the time range limits", + "items": { + "type": "object" + }, + "type": "array" + }, "viz_error": { "description": "Error that occurred when warming cache for chart", "type": "string" @@ -7985,6 +8174,11 @@ }, "slice": { "$ref": "#/components/schemas/Slice" + }, + "time_range_limits": { + "description": "The global and dataset time range limits and the server's time zone, or an error object when the limits can't be loaded. Only when time range limits are on.", + "nullable": true, + "type": "object" } }, "type": "object" @@ -14380,6 +14574,445 @@ ], "type": "object" }, + "TimeRangeLimitsChartLink": { + "properties": { + "id": { + "type": "integer" + }, + "slice_name": { + "type": "string" + }, + "url": { + "description": "The chart's Explore URL.", + "type": "string" + } + }, + "type": "object" + }, + "TimeRangeLimitsChartsSchema": { + "properties": { + "charts": { + "description": "The saved charts the policy would refuse.", + "items": { + "$ref": "#/components/schemas/TimeRangeLimitsRefusedChart" + }, + "type": "array" + }, + "filters": { + "description": "The time range filter defaults it would refuse.", + "items": { + "$ref": "#/components/schemas/TimeRangeLimitsRefusedFilter" + }, + "type": "array" + }, + "truncated": { + "description": "The dashboard or dataset has more readable charts than the first 50 checked.", + "type": "boolean" + }, + "unchecked": { + "description": "Charts whose query can't be built.", + "items": { + "$ref": "#/components/schemas/TimeRangeLimitsChartLink" + }, + "type": "array" + }, + "unlimited": { + "description": "Charts on a dataset with no temporal column.", + "items": { + "$ref": "#/components/schemas/TimeRangeLimitsChartLink" + }, + "type": "array" + } + }, + "type": "object" + }, + "TimeRangeLimitsConstraint": { + "properties": { + "quantity": { + "description": "How many time grains.", + "type": "integer" + }, + "source": { + "description": "global or dashboard", + "type": "string" + }, + "time_grain": { + "description": "The ISO 8601 time grain, e.g. P1W.", + "type": "string" + } + }, + "type": "object" + }, + "TimeRangeLimitsDashboardSchema": { + "properties": { + "dashboard": { + "$ref": "#/components/schemas/TimeRangeLimitsPolicy" + }, + "datasets": { + "description": "The dashboard's chart datasets with a policy that the caller can read, including authorized embedded member datasets. Datasets the caller cannot read are omitted; their charts are not held client-side because of that omission and retain server access and limit checks.", + "items": { + "$ref": "#/components/schemas/TimeRangeLimitsDataset" + }, + "type": "array" + }, + "editor_notice": { + "allOf": [ + { + "$ref": "#/components/schemas/TimeRangeLimitsEditorNotice" + } + ], + "description": "The notice the current user gets as a dashboard editor when the global time range limits turned on or got stricter since they acknowledged them; null for everyone else.", + "nullable": true + }, + "effective": { + "$ref": "#/components/schemas/TimeRangeLimitsEffective" + }, + "eligible_time_grains": { + "description": "The time grains a rule may use.", + "items": { + "$ref": "#/components/schemas/TimeRangeLimitsTimeGrain" + }, + "type": "array" + }, + "global": { + "$ref": "#/components/schemas/TimeRangeLimitsPolicy" + }, + "global_revision": { + "description": "Always 0.", + "type": "integer" + }, + "needs_review": { + "items": { + "$ref": "#/components/schemas/TimeRangeLimitsNeedsReview" + }, + "type": "array" + }, + "schema_version": { + "type": "integer" + }, + "time_grain_labels": { + "additionalProperties": { + "type": "string" + }, + "description": "The label of every time grain the registry knows, eligible or not, by value.", + "type": "object" + }, + "timezone": { + "description": "The server's time zone, to show: an IANA name when TZ is set, else an abbreviation such as CEST.", + "type": "string" + } + }, + "type": "object" + }, + "TimeRangeLimitsDataset": { + "properties": { + "error": { + "allOf": [ + { + "$ref": "#/components/schemas/TimeRangeLimitsDatasetError" + } + ], + "description": "Present when this dataset's policy cannot be loaded; policy is null. Only this dataset's charts must remain held." + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "policy": { + "allOf": [ + { + "$ref": "#/components/schemas/TimeRangeLimitsPolicy" + } + ], + "nullable": true + } + }, + "type": "object" + }, + "TimeRangeLimitsDatasetError": { + "properties": { + "error_type": { + "type": "string" + }, + "extra": { + "description": "POLICY_UNAVAILABLE details; stored value is included only for dataset editors on GET and is omitted on PUT.", + "type": "object" + }, + "message": { + "type": "string" + } + }, + "type": "object" + }, + "TimeRangeLimitsDatasetLimitsSchema": { + "properties": { + "dataset": { + "$ref": "#/components/schemas/TimeRangeLimitsPolicy" + }, + "eligible_time_grains": { + "description": "The time grains a rule may use.", + "items": { + "$ref": "#/components/schemas/TimeRangeLimitsTimeGrain" + }, + "type": "array" + }, + "global": { + "$ref": "#/components/schemas/TimeRangeLimitsPolicy" + }, + "global_revision": { + "description": "Always 0.", + "type": "integer" + }, + "needs_review": { + "items": { + "$ref": "#/components/schemas/TimeRangeLimitsNeedsReview" + }, + "type": "array" + }, + "schema_version": { + "type": "integer" + }, + "time_grain_labels": { + "additionalProperties": { + "type": "string" + }, + "description": "The label of every time grain the registry knows, eligible or not, by value.", + "type": "object" + } + }, + "type": "object" + }, + "TimeRangeLimitsDisplayLabels": { + "properties": { + "max_look_back": { + "description": "e.g. 12 weeks; null when unset.", + "nullable": true, + "type": "string" + }, + "max_look_forward": { + "description": "e.g. 12 weeks; null when unset.", + "nullable": true, + "type": "string" + }, + "max_span": { + "description": "e.g. 12 weeks; null when unset.", + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, + "TimeRangeLimitsEditorNotice": { + "properties": { + "kind": { + "description": "turned_on or tightened", + "enum": [ + "turned_on", + "tightened" + ], + "type": "string" + } + }, + "type": "object" + }, + "TimeRangeLimitsEffective": { + "properties": { + "active": { + "type": "boolean" + }, + "max_look_back": { + "$ref": "#/components/schemas/TimeRangeLimitsEffectiveRule" + }, + "max_look_forward": { + "$ref": "#/components/schemas/TimeRangeLimitsEffectiveRule" + }, + "max_span": { + "$ref": "#/components/schemas/TimeRangeLimitsEffectiveRule" + } + }, + "type": "object" + }, + "TimeRangeLimitsEffectiveRule": { + "properties": { + "constraints": { + "items": { + "$ref": "#/components/schemas/TimeRangeLimitsConstraint" + }, + "type": "array" + }, + "display_label": { + "type": "string" + } + }, + "type": "object" + }, + "TimeRangeLimitsNeedsReview": { + "properties": { + "reason": { + "description": "LOOSER_THAN_GLOBAL or UNIT_MISMATCH", + "type": "string" + }, + "rule": { + "type": "string" + } + }, + "type": "object" + }, + "TimeRangeLimitsPolicy": { + "properties": { + "max_look_back": { + "allOf": [ + { + "$ref": "#/components/schemas/TimeRangeLimitsRule" + } + ], + "nullable": true + }, + "max_look_forward": { + "allOf": [ + { + "$ref": "#/components/schemas/TimeRangeLimitsRule" + } + ], + "nullable": true + }, + "max_span": { + "allOf": [ + { + "$ref": "#/components/schemas/TimeRangeLimitsRule" + } + ], + "nullable": true + } + }, + "type": "object" + }, + "TimeRangeLimitsRefusedChart": { + "properties": { + "id": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "range": { + "description": "The chart's saved time range; null: No filter.", + "nullable": true, + "type": "string" + }, + "reason_code": { + "type": "string" + }, + "rule": { + "nullable": true, + "type": "string" + }, + "slice_name": { + "type": "string" + }, + "source": { + "nullable": true, + "type": "string" + }, + "time_offset": { + "description": "The time comparison that fails, else null.", + "nullable": true, + "type": "string" + }, + "url": { + "description": "The chart's Explore URL.", + "type": "string" + } + }, + "type": "object" + }, + "TimeRangeLimitsRefusedFilter": { + "properties": { + "id": { + "type": "string" + }, + "message": { + "nullable": true, + "type": "string" + }, + "name": { + "type": "string" + }, + "no_default": { + "description": "The filter has no default, or No filter.", + "type": "boolean" + }, + "range": { + "description": "The filter's default value.", + "nullable": true, + "type": "string" + }, + "reason_code": { + "nullable": true, + "type": "string" + }, + "rule": { + "nullable": true, + "type": "string" + }, + "source": { + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, + "TimeRangeLimitsResponseSchema": { + "properties": { + "display_labels": { + "$ref": "#/components/schemas/TimeRangeLimitsDisplayLabels" + }, + "eligible_time_grains": { + "description": "The time grains a rule may use.", + "items": { + "$ref": "#/components/schemas/TimeRangeLimitsTimeGrain" + }, + "type": "array" + }, + "policy": { + "$ref": "#/components/schemas/TimeRangeLimitsPolicy" + }, + "revision": { + "description": "Always 0: the global policy comes from config.", + "type": "integer" + }, + "schema_version": { + "type": "integer" + } + }, + "type": "object" + }, + "TimeRangeLimitsRule": { + "properties": { + "quantity": { + "description": "How many time grains.", + "type": "integer" + }, + "time_grain": { + "description": "The ISO 8601 time grain, e.g. P1W.", + "type": "string" + } + }, + "type": "object" + }, + "TimeRangeLimitsTimeGrain": { + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "type": "object" + }, "UploadFileMetadata": { "properties": { "items": { @@ -15361,6 +15994,12 @@ }, "type": "object" }, + "get_time_range_limits_charts_schema": { + "properties": { + "policy": {} + }, + "type": "object" + }, "queries_get_updated_since_schema": { "properties": { "last_updated_ms": { @@ -19334,6 +19973,9 @@ }, "500": { "$ref": "#/components/responses/500" + }, + "503": { + "$ref": "#/components/responses/503" } }, "security": [ @@ -20020,6 +20662,9 @@ }, "500": { "$ref": "#/components/responses/500" + }, + "503": { + "$ref": "#/components/responses/503" } }, "security": [ @@ -20247,16 +20892,190 @@ "api_key": [] } ], - "summary": "Set a dashboard's embedded configuration", + "summary": "Set a dashboard's embedded configuration", + "tags": [ + "Dashboards" + ] + }, + "put": { + "description": "Sets a dashboard's embedded configuration.", + "parameters": [ + { + "description": "The dashboard id or slug", + "in": "path", + "name": "id_or_slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmbeddedDashboardConfig" + } + } + }, + "description": "The embedded configuration to set", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "result": { + "$ref": "#/components/schemas/EmbeddedDashboardResponseSchema" + } + }, + "type": "object" + } + } + }, + "description": "Successfully set the configuration" + }, + "401": { + "$ref": "#/components/responses/401" + }, + "500": { + "$ref": "#/components/responses/500" + } + }, + "security": [ + { + "jwt": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Dashboards" + ] + } + }, + "/api/v1/dashboard/{id_or_slug}/tabs": { + "get": { + "description": "Returns a list of a dashboard's tabs and dashboard's nested tree structure for associated tabs.", + "parameters": [ + { + "description": "Either the id of the dashboard, or its slug", + "in": "path", + "name": "id_or_slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "result": { + "items": { + "$ref": "#/components/schemas/TabsPayloadSchema" + }, + "type": "object" + } + }, + "type": "object" + } + } + }, + "description": "Dashboard tabs" + }, + "400": { + "$ref": "#/components/responses/400" + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + } + }, + "security": [ + { + "jwt": [] + }, + { + "api_key": [] + } + ], + "summary": "Get dashboard's tabs", + "tags": [ + "Dashboards" + ] + } + }, + "/api/v1/dashboard/{id_or_slug}/time_range_limits/": { + "get": { + "parameters": [ + { + "description": "Either the id of the dashboard, or its slug", + "in": "path", + "name": "id_or_slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "result": { + "$ref": "#/components/schemas/TimeRangeLimitsDashboardSchema" + } + }, + "type": "object" + } + } + }, + "description": "The global, dashboard and effective time range limits" + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + }, + "503": { + "$ref": "#/components/responses/503" + } + }, + "security": [ + { + "jwt": [] + }, + { + "api_key": [] + } + ], + "summary": "Get the time range limits of a dashboard", "tags": [ "Dashboards" ] }, "put": { - "description": "Sets a dashboard's embedded configuration.", "parameters": [ { - "description": "The dashboard id or slug", + "description": "Either the id of the dashboard, or its slug", "in": "path", "name": "id_or_slug", "required": true, @@ -20269,11 +21088,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EmbeddedDashboardConfig" + "$ref": "#/components/schemas/DashboardTimeRangeLimitsPutSchema" } } }, - "description": "The embedded configuration to set", + "description": "The dashboard's time range limits", "required": true }, "responses": { @@ -20283,20 +21102,32 @@ "schema": { "properties": { "result": { - "$ref": "#/components/schemas/EmbeddedDashboardResponseSchema" + "$ref": "#/components/schemas/TimeRangeLimitsDashboardSchema" } }, "type": "object" } } }, - "description": "Successfully set the configuration" + "description": "The global, dashboard and effective time range limits" + }, + "400": { + "$ref": "#/components/responses/400" }, "401": { "$ref": "#/components/responses/401" }, - "500": { - "$ref": "#/components/responses/500" + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + }, + "422": { + "$ref": "#/components/responses/422" + }, + "503": { + "$ref": "#/components/responses/503" } }, "security": [ @@ -20307,14 +21138,15 @@ "api_key": [] } ], + "summary": "Replace or remove the time range limits of a dashboard", "tags": [ "Dashboards" ] } }, - "/api/v1/dashboard/{id_or_slug}/tabs": { + "/api/v1/dashboard/{id_or_slug}/time_range_limits/charts/": { "get": { - "description": "Returns a list of a dashboard's tabs and dashboard's nested tree structure for associated tabs.", + "description": "Checks the dashboard's saved charts and time range filter defaults against a proposed dashboard policy, as chart data queries are checked. Writes nothing and runs no data query.", "parameters": [ { "description": "Either the id of the dashboard, or its slug", @@ -20324,6 +21156,17 @@ "schema": { "type": "string" } + }, + { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/get_time_range_limits_charts_schema" + } + } + }, + "in": "query", + "name": "q" } ], "responses": { @@ -20333,17 +21176,14 @@ "schema": { "properties": { "result": { - "items": { - "$ref": "#/components/schemas/TabsPayloadSchema" - }, - "type": "object" + "$ref": "#/components/schemas/TimeRangeLimitsChartsSchema" } }, "type": "object" } } }, - "description": "Dashboard tabs" + "description": "The charts and filter defaults the policy would refuse" }, "400": { "$ref": "#/components/responses/400" @@ -20356,6 +21196,12 @@ }, "404": { "$ref": "#/components/responses/404" + }, + "422": { + "$ref": "#/components/responses/422" + }, + "503": { + "$ref": "#/components/responses/503" } }, "security": [ @@ -20366,7 +21212,7 @@ "api_key": [] } ], - "summary": "Get dashboard's tabs", + "summary": "List the charts and filter defaults a dashboard policy would refuse", "tags": [ "Dashboards" ] @@ -20522,6 +21368,9 @@ }, "500": { "$ref": "#/components/responses/500" + }, + "503": { + "$ref": "#/components/responses/503" } }, "security": [ @@ -22124,6 +22973,9 @@ }, "422": { "$ref": "#/components/responses/422" + }, + "503": { + "$ref": "#/components/responses/503" } }, "security": [ @@ -25585,6 +26437,9 @@ }, "500": { "$ref": "#/components/responses/500" + }, + "503": { + "$ref": "#/components/responses/503" } }, "security": [ @@ -25790,15 +26645,247 @@ "api_key": [] } ], - "summary": "Delete a dataset metric", + "summary": "Delete a dataset metric", + "tags": [ + "Datasets" + ] + } + }, + "/api/v1/dataset/{pk}/partition_mapping/preview/": { + "post": { + "description": "Evaluate a partition value transform at sample values and return the predicate that would be appended to queries. The predicate is built by the same code the query path uses, so the preview cannot drift from what a chart emits. Parse and denylist checks run before anything reaches the engine, so an unparseable transform costs no query.", + "parameters": [ + { + "description": "The dataset ID", + "in": "path", + "name": "pk", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PartitionMappingPreviewSchema" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "result": { + "properties": { + "data_validation": { + "properties": { + "checked_rows": { + "type": "integer" + }, + "examples": { + "items": { + "properties": { + "source_value": { + "type": "string" + }, + "stored_key": { + "type": "string" + }, + "transformed_value": { + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "inconclusive_keys": { + "description": "Partition keys whose rows could not be read, present when the status is inconclusive.", + "items": { + "type": "string" + }, + "type": "array" + }, + "message": { + "type": "string" + }, + "mismatch_count": { + "type": "integer" + }, + "partitions_checked": { + "type": "integer" + }, + "partitions_requested": { + "type": "integer" + }, + "skipped_rows": { + "type": "integer" + }, + "status": { + "enum": [ + "not_checked", + "matched", + "mismatch", + "inconclusive", + "refused" + ], + "type": "string" + } + }, + "type": "object" + }, + "emitted_predicate": { + "type": "string" + }, + "error": { + "type": "string" + }, + "reason": { + "description": "Why predicate evaluation was declined. Absent on evaluation success. The editor branches on this to decide how to present the failure.", + "enum": [ + "unbucketed_epoch", + "unconfigured", + "validation", + "parse", + "operator", + "resolution", + "offset", + "escaping", + "transform_escaping", + "engine", + "type" + ], + "type": "string" + }, + "sample_input": { + "type": "string" + }, + "valid": { + "description": "Expression/predicate evaluation succeeded; not a guarantee of partition correspondence.", + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + }, + "description": "Preview result" + }, + "400": { + "$ref": "#/components/responses/400" + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + }, + "429": { + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Too many preview requests for this dataset" + }, + "500": { + "$ref": "#/components/responses/500" + } + }, + "security": [ + { + "jwt": [] + }, + { + "api_key": [] + } + ], + "summary": "Preview a partition filter mapping", + "tags": [ + "Datasets" + ] + } + }, + "/api/v1/dataset/{pk}/refresh": { + "put": { + "parameters": [ + { + "in": "path", + "name": "pk", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Dataset delete" + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + }, + "422": { + "$ref": "#/components/responses/422" + }, + "500": { + "$ref": "#/components/responses/500" + } + }, + "security": [ + { + "jwt": [] + }, + { + "api_key": [] + } + ], + "summary": "Refresh and update columns of a dataset", "tags": [ "Datasets" ] } }, - "/api/v1/dataset/{pk}/partition_mapping/preview/": { - "post": { - "description": "Evaluate a partition value transform at sample values and return the predicate that would be appended to queries. The predicate is built by the same code the query path uses, so the preview cannot drift from what a chart emits. Parse and denylist checks run before anything reaches the engine, so an unparseable transform costs no query.", + "/api/v1/dataset/{pk}/time_range_limits/": { + "get": { "parameters": [ { "description": "The dataset ID", @@ -25810,16 +26897,6 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PartitionMappingPreviewSchema" - } - } - }, - "required": true - }, "responses": { "200": { "content": { @@ -25827,107 +26904,14 @@ "schema": { "properties": { "result": { - "properties": { - "data_validation": { - "properties": { - "checked_rows": { - "type": "integer" - }, - "examples": { - "items": { - "properties": { - "source_value": { - "type": "string" - }, - "stored_key": { - "type": "string" - }, - "transformed_value": { - "nullable": true, - "type": "string" - } - }, - "type": "object" - }, - "type": "array" - }, - "inconclusive_keys": { - "description": "Partition keys whose rows could not be read, present when the status is inconclusive.", - "items": { - "type": "string" - }, - "type": "array" - }, - "message": { - "type": "string" - }, - "mismatch_count": { - "type": "integer" - }, - "partitions_checked": { - "type": "integer" - }, - "partitions_requested": { - "type": "integer" - }, - "skipped_rows": { - "type": "integer" - }, - "status": { - "enum": [ - "not_checked", - "matched", - "mismatch", - "inconclusive", - "refused" - ], - "type": "string" - } - }, - "type": "object" - }, - "emitted_predicate": { - "type": "string" - }, - "error": { - "type": "string" - }, - "reason": { - "description": "Why predicate evaluation was declined. Absent on evaluation success. The editor branches on this to decide how to present the failure.", - "enum": [ - "unbucketed_epoch", - "unconfigured", - "validation", - "parse", - "operator", - "resolution", - "offset", - "escaping", - "transform_escaping", - "engine", - "type" - ], - "type": "string" - }, - "sample_input": { - "type": "string" - }, - "valid": { - "description": "Expression/predicate evaluation succeeded; not a guarantee of partition correspondence.", - "type": "boolean" - } - }, - "type": "object" + "$ref": "#/components/schemas/TimeRangeLimitsDatasetLimitsSchema" } }, "type": "object" } } }, - "description": "Preview result" - }, - "400": { - "$ref": "#/components/responses/400" + "description": "The global and dataset time range limits" }, "401": { "$ref": "#/components/responses/401" @@ -25938,23 +26922,8 @@ "404": { "$ref": "#/components/responses/404" }, - "429": { - "content": { - "application/json": { - "schema": { - "properties": { - "message": { - "type": "string" - } - }, - "type": "object" - } - } - }, - "description": "Too many preview requests for this dataset" - }, - "500": { - "$ref": "#/components/responses/500" + "503": { + "$ref": "#/components/responses/503" } }, "security": [ @@ -25965,22 +26934,35 @@ "api_key": [] } ], - "summary": "Preview a partition filter mapping", + "summary": "Get the time range limits of a dataset", "tags": [ "Datasets" ] } }, - "/api/v1/dataset/{pk}/refresh": { - "put": { + "/api/v1/dataset/{pk}/time_range_limits/charts/": { + "get": { + "description": "Checks the saved charts on the dataset that the user can see against a proposed dataset policy, as chart data queries outside a dashboard are checked. Writes nothing and runs no data query.", "parameters": [ { + "description": "The dataset ID", "in": "path", "name": "pk", "required": true, "schema": { "type": "integer" } + }, + { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/get_time_range_limits_charts_schema" + } + } + }, + "in": "query", + "name": "q" } ], "responses": { @@ -25989,15 +26971,18 @@ "application/json": { "schema": { "properties": { - "message": { - "type": "string" + "result": { + "$ref": "#/components/schemas/TimeRangeLimitsChartsSchema" } }, "type": "object" } } }, - "description": "Dataset delete" + "description": "The charts the policy would refuse" + }, + "400": { + "$ref": "#/components/responses/400" }, "401": { "$ref": "#/components/responses/401" @@ -26011,8 +26996,8 @@ "422": { "$ref": "#/components/responses/422" }, - "500": { - "$ref": "#/components/responses/500" + "503": { + "$ref": "#/components/responses/503" } }, "security": [ @@ -26023,7 +27008,7 @@ "api_key": [] } ], - "summary": "Refresh and update columns of a dataset", + "summary": "List the charts on a dataset that a dataset policy would refuse", "tags": [ "Datasets" ] @@ -26329,6 +27314,9 @@ }, "422": { "$ref": "#/components/responses/422" + }, + "503": { + "$ref": "#/components/responses/503" } }, "security": [ @@ -38697,6 +39685,148 @@ ] } }, + "/api/v1/time_range_limits/": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "result": { + "$ref": "#/components/schemas/TimeRangeLimitsResponseSchema" + } + }, + "type": "object" + } + } + }, + "description": "The global time range limits" + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + }, + "503": { + "$ref": "#/components/responses/503" + } + }, + "security": [ + { + "jwt": [] + }, + { + "api_key": [] + } + ], + "summary": "Get the global time range limits", + "tags": [ + "Time Range Limits" + ] + } + }, + "/api/v1/time_range_limits/chart_ranges/": { + "post": { + "description": "Each entry is the body a chart sends to its data route. Each chart gets its ranges, as the time range limits check of that route reads them, or the status and body that route answers with. No query runs.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChartRangesRequestSchema" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "result": { + "$ref": "#/components/schemas/ChartRangesResponseSchema" + } + }, + "type": "object" + } + } + }, + "description": "Each chart's ranges or error, and the server clock" + }, + "400": { + "$ref": "#/components/responses/400" + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + } + }, + "security": [ + { + "jwt": [] + }, + { + "api_key": [] + } + ], + "summary": "Get the time ranges of chart data requests, without running them", + "tags": [ + "Time Range Limits" + ] + } + }, + "/api/v1/time_range_limits/notice/": { + "put": { + "description": "Stores the current global time range limits as the ones the current user acknowledged, so their editor notice hides until the limits get stricter. The request body is ignored.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "The limits are acknowledged" + }, + "401": { + "$ref": "#/components/responses/401" + }, + "403": { + "$ref": "#/components/responses/403" + }, + "404": { + "$ref": "#/components/responses/404" + }, + "503": { + "$ref": "#/components/responses/503" + } + }, + "security": [ + { + "jwt": [] + }, + { + "api_key": [] + } + ], + "summary": "Acknowledge the time range limits notice", + "tags": [ + "Time Range Limits" + ] + } + }, "/api/v1/user/{user_id}/avatar.png": { "get": { "description": "Gets the avatar URL for the user with the given ID, or returns a 401 error if the user is unauthenticated.", diff --git a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx index c98b93a0451c..ce91b2e1ff74 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx @@ -48,6 +48,7 @@ import { ClearOutlined, CloseOutlined, CloseCircleOutlined, + CloseCircleFilled, ClockCircleOutlined, ColumnHeightOutlined, ColumnWidthOutlined, @@ -204,6 +205,7 @@ const AntdIcons = { ClearOutlined, CloseOutlined, CloseCircleOutlined, + CloseCircleFilled, ClockCircleOutlined, ColumnHeightOutlined, ColumnWidthOutlined, diff --git a/superset-frontend/packages/superset-ui-core/src/components/Radio/Radio.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/Radio/Radio.test.tsx index 46e9465ce36b..b6e3a9c67f0d 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Radio/Radio.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Radio/Radio.test.tsx @@ -44,3 +44,72 @@ describe('Radio Component', () => { expect(option2).toBeChecked(); }); }); + +test('GroupWrapper disables only the options marked disabled', () => { + const onChange = jest.fn(); + render( + , + ); + + expect(screen.getByLabelText('Option 1')).toBeEnabled(); + expect(screen.getByLabelText('Option 2')).toBeDisabled(); + expect(screen.getByLabelText('Option 3')).toBeEnabled(); + + fireEvent.click(screen.getByLabelText('Option 2')); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByLabelText('Option 2')).not.toBeChecked(); +}); + +test('GroupWrapper still disables every option from the group', () => { + render( + , + ); + + expect(screen.getByLabelText('Option 1')).toBeDisabled(); + expect(screen.getByLabelText('Option 2')).toBeDisabled(); +}); + +test('GroupWrapper forwards accessibility attributes to individual options', () => { + render( + , + ); + expect(screen.getByRole('radio', { name: 'Invalid range' })).toHaveAttribute( + 'aria-invalid', + 'true', + ); + expect(screen.getByRole('radio', { name: 'Invalid range' })).toHaveAttribute( + 'aria-describedby', + 'range-error', + ); + expect( + screen.getByRole('radio', { name: 'Valid range' }), + ).not.toHaveAttribute('aria-invalid'); + expect( + screen.getByRole('radio', { name: 'Valid range' }), + ).not.toHaveAttribute('aria-describedby'); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/components/Radio/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/Radio/index.tsx index 0d007db7ce4b..b0fc3c89e1ab 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Radio/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Radio/index.tsx @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import type { AriaAttributes } from 'react'; import { Radio as AntRadio, type CheckboxOptionType, @@ -24,14 +25,15 @@ import { import { Space, type SpaceProps } from '../Space'; -export type RadioGroupWrapperProps = RadioGroupProps & { +export type RadioGroupWrapperProps = Omit & { spaceConfig?: { direction?: SpaceProps['direction']; size?: SpaceProps['size']; align?: SpaceProps['align']; wrap?: SpaceProps['wrap']; }; - options: CheckboxOptionType[]; + options: (CheckboxOptionType & + Pick)[]; }; const RadioGroup = ({ @@ -39,11 +41,19 @@ const RadioGroup = ({ options, ...props }: RadioGroupWrapperProps) => { - const content = options.map((option: CheckboxOptionType) => ( - - {option.label} - - )); + const content = options.map( + (option: RadioGroupWrapperProps['options'][number]) => ( + + {option.label} + + ), + ); return ( {spaceConfig ? {content} : content} diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts index 0346c2eb25b2..692342a7837a 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts @@ -237,6 +237,7 @@ export const ErrorTypeEnum = { ADHOC_SUBQUERY_NOT_ALLOWED_ERROR: 'ADHOC_SUBQUERY_NOT_ALLOWED_ERROR', INVALID_SQL_ERROR: 'INVALID_SQL_ERROR', RESULT_TOO_LARGE_ERROR: 'RESULT_TOO_LARGE_ERROR', + TIME_RANGE_LIMIT_ERROR: 'TIME_RANGE_LIMIT_ERROR', // Generic errors GENERIC_COMMAND_ERROR: 'GENERIC_COMMAND_ERROR', diff --git a/superset-frontend/packages/superset-ui-core/src/time-comparison/fetchTimeRange.ts b/superset-frontend/packages/superset-ui-core/src/time-comparison/fetchTimeRange.ts index 131bbb333c84..10263853e1b1 100644 --- a/superset-frontend/packages/superset-ui-core/src/time-comparison/fetchTimeRange.ts +++ b/superset-frontend/packages/superset-ui-core/src/time-comparison/fetchTimeRange.ts @@ -23,6 +23,7 @@ import { getClientErrorObject, ensureIsArray, getTimeFormatter, + type RequestConfig, } from '@superset-ui/core'; import { extendedDayjs } from '@superset-ui/core/utils/dates'; @@ -137,3 +138,118 @@ export const fetchTimeRange = async ( }; } }; + +export type ResolvedTimeRange = { + since: string | null; + until: string | null; +}; + +export type ResolvedTimeRanges = { + range: ResolvedTimeRange; + today: string; + now: string; + shifts: (ResolvedTimeRange & { shift: string })[]; +}; + +type TimeRangeResult = { + since?: string; + until?: string; +}; + +const resolved = (result?: TimeRangeResult): ResolvedTimeRange => ({ + since: result?.since || null, + until: result?.until || null, +}); + +export type TimeRangeError = { + error: string; + timedOut?: boolean; + status?: number; +}; + +export type TimeRangeRequestOptions = Pick< + RequestConfig, + 'timeout' | 'fetchRetryOptions' +>; + +export const fetchTimeRanges = async ( + timeRange: string, + shifts: string[] = [], + options?: TimeRangeRequestOptions, +): Promise => { + const query = rison.encode_uri([ + { timeRange }, + { timeRange: 'today : now' }, + ...shifts.map(shift => ({ timeRange, shift })), + ]); + try { + const response = await SupersetClient.get({ + endpoint: `/api/v1/time_range/?q=${query}`, + ...options, + }); + const results: TimeRangeResult[] = response?.json?.result ?? []; + const [range, clock, ...shifted] = results; + return { + range: resolved(range), + today: clock?.since ?? '', + now: clock?.until ?? '', + shifts: shifts.map((shift, index) => ({ + shift, + ...resolved(shifted[index]), + })), + }; + } catch (response) { + const clientError = await getClientErrorObject(response); + return { + error: clientError.message || clientError.error || response.statusText, + ...(response?.statusText === 'timeout' ? { timedOut: true } : {}), + ...(response?.status ? { status: response.status } : {}), + }; + } +}; + +export const formatResolvedTimeRange = ( + { since, until }: ResolvedTimeRange, + dateFormat?: string, +): string => + formatTimeRange( + buildTimeRangeString(since ?? '', until ?? ''), + 'col', + dateFormat, + ); + +export type ResolvedTimeRangeList = { + today: string; + now: string; + ranges: ResolvedTimeRange[]; +}; + +export const fetchTimeRangeList = async ( + timeRanges: string[], + options?: TimeRangeRequestOptions, +): Promise => { + const query = rison.encode_uri([ + ...timeRanges.map(timeRange => ({ timeRange })), + { timeRange: 'today : now' }, + ]); + try { + const response = await SupersetClient.get({ + endpoint: `/api/v1/time_range/?q=${query}`, + ...options, + }); + const results: TimeRangeResult[] = response?.json?.result ?? []; + const clock = results[timeRanges.length]; + return { + today: clock?.since ?? '', + now: clock?.until ?? '', + ranges: timeRanges.map((_, index) => resolved(results[index])), + }; + } catch (response) { + const clientError = await getClientErrorObject(response); + return { + error: clientError.message || clientError.error || response.statusText, + ...(response?.statusText === 'timeout' ? { timedOut: true } : {}), + ...(response?.status ? { status: response.status } : {}), + }; + } +}; diff --git a/superset-frontend/packages/superset-ui-core/src/time-comparison/index.ts b/superset-frontend/packages/superset-ui-core/src/time-comparison/index.ts index 94f8ea4527d5..08a2a66a77e6 100644 --- a/superset-frontend/packages/superset-ui-core/src/time-comparison/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/time-comparison/index.ts @@ -26,5 +26,18 @@ export { getTimeOffset, computeCustomDateTime, } from './getTimeOffset'; -export { SEPARATOR, fetchTimeRange } from './fetchTimeRange'; +export { + SEPARATOR, + fetchTimeRange, + fetchTimeRanges, + fetchTimeRangeList, + formatResolvedTimeRange, +} from './fetchTimeRange'; +export type { + ResolvedTimeRange, + ResolvedTimeRanges, + ResolvedTimeRangeList, + TimeRangeError, + TimeRangeRequestOptions, +} from './fetchTimeRange'; export { customTimeRangeDecode } from './customTimeRangeDecode'; diff --git a/superset-frontend/packages/superset-ui-core/src/time-comparison/types.ts b/superset-frontend/packages/superset-ui-core/src/time-comparison/types.ts index 5c4f564be863..5cae710d31c1 100644 --- a/superset-frontend/packages/superset-ui-core/src/time-comparison/types.ts +++ b/superset-frontend/packages/superset-ui-core/src/time-comparison/types.ts @@ -70,3 +70,29 @@ export type CustomRangeDecodeType = { customRange: CustomRangeType; matchedFlag: boolean; }; + +/** + * Time range limits, in the shape the server's limit payloads send them + */ + +export type Rule = { quantity: number; time_grain: string } | null; + +export type Policy = { + max_span: Rule; + max_look_back: Rule; + max_look_forward: Rule; +}; + +export type Source = 'global' | 'dataset' | 'dashboard'; + +export type Level = { source: Source; policy: Policy | null }; + +export type TimeRangeLimits = { + levels: Level[]; + dashboardId?: number; + timezone?: string; + // Id of an error shown outside the picker that describes its trigger. + describedBy?: string; + // Id of the label outside the picker that names its trigger. + labelledBy?: string; +}; diff --git a/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts b/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts index fd5496ecffcb..0340f0939497 100644 --- a/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts +++ b/superset-frontend/packages/superset-ui-core/src/ui-overrides/types.ts @@ -31,6 +31,7 @@ import type { } from '../query'; import type { JsonResponse } from '../connection'; import type { MenuItem } from '../components/Menu'; +import type { TimeRangeLimits } from '../time-comparison'; /** * A function which returns text (or marked-up text) @@ -231,6 +232,7 @@ export interface DateFilterControlProps { overlayStyle?: 'Modal' | 'Popover'; isOverflowingFilterBar?: boolean; displayFormat?: string; + timeRangeLimits?: TimeRangeLimits; } export interface ExploreChartHeaderProps { diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts index c55045a80e3d..71aad2874457 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts @@ -69,6 +69,7 @@ export enum FeatureFlag { SshTunneling = 'SSH_TUNNELING', TaggingSystem = 'TAGGING_SYSTEM', Thumbnails = 'THUMBNAILS', + TimeRangeLimits = 'TIME_RANGE_LIMITS', UseAnalogousColors = 'USE_ANALOGOUS_COLORS', VersionHistory = 'VERSION_HISTORY', ForceSqlLabRunAsync = 'SQLLAB_FORCE_RUN_ASYNC', diff --git a/superset-frontend/packages/superset-ui-core/test/time-comparison/fetchTimeRange.test.ts b/superset-frontend/packages/superset-ui-core/test/time-comparison/fetchTimeRange.test.ts index 74da87973712..2b5bfcb557f0 100644 --- a/superset-frontend/packages/superset-ui-core/test/time-comparison/fetchTimeRange.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/time-comparison/fetchTimeRange.test.ts @@ -18,7 +18,14 @@ */ import fetchMock from 'fetch-mock'; -import { fetchTimeRange } from '@superset-ui/core'; +import rison from 'rison'; +import { + SupersetClient, + fetchTimeRange, + fetchTimeRangeList, + fetchTimeRanges, + formatResolvedTimeRange, +} from '@superset-ui/core'; import { buildTimeRangeString, formatTimeRange, @@ -239,3 +246,266 @@ test('formatTimeRangeComparison', () => { 'col_name: 2021-04-13 to 2021-04-14 vs\n 2021-03-13 to 2021-03-14', ); }); + +const timeRangeQuery = () => { + const url = new URL(fetchMock.callHistory.lastCall()!.url); + return rison.decode(url.searchParams.get('q')!); +}; + +test('fetchTimeRanges resolves the range, the server clock and every shift in one request', async () => { + fetchMock.get('glob:*/api/v1/time_range/?q=*', { + result: [ + { + since: '2025-02-28T00:00:00', + until: '2025-05-31T00:00:00', + timeRange: 'Last 3 months', + shift: null, + }, + { + since: '2025-05-31T00:00:00', + until: '2025-05-31T14:30:00', + timeRange: 'today : now', + shift: null, + }, + { + since: '2024-02-28T00:00:00', + until: '2024-05-31T00:00:00', + timeRange: 'Last 3 months', + shift: '1 year ago', + }, + { + since: '2025-02-21T00:00:00', + until: '2025-05-24T00:00:00', + timeRange: 'Last 3 months', + shift: '1 week ago', + }, + ], + }); + + const result = await fetchTimeRanges('Last 3 months', [ + '1 year ago', + '1 week ago', + ]); + + expect(fetchMock.callHistory.calls()).toHaveLength(1); + expect(timeRangeQuery()).toEqual([ + { timeRange: 'Last 3 months' }, + { timeRange: 'today : now' }, + { timeRange: 'Last 3 months', shift: '1 year ago' }, + { timeRange: 'Last 3 months', shift: '1 week ago' }, + ]); + expect(result).toEqual({ + range: { since: '2025-02-28T00:00:00', until: '2025-05-31T00:00:00' }, + today: '2025-05-31T00:00:00', + now: '2025-05-31T14:30:00', + shifts: [ + { + shift: '1 year ago', + since: '2024-02-28T00:00:00', + until: '2024-05-31T00:00:00', + }, + { + shift: '1 week ago', + since: '2025-02-21T00:00:00', + until: '2025-05-24T00:00:00', + }, + ], + }); +}); + +test('fetchTimeRanges returns an open end as null', async () => { + fetchMock.get('glob:*/api/v1/time_range/?q=*', { + result: [ + { since: '2025-05-01T00:00:00', until: '', timeRange: '...' }, + { + since: '2025-05-31T00:00:00', + until: '2025-05-31T14:30:00', + timeRange: 'today : now', + }, + ], + }); + + const result = await fetchTimeRanges('2025-05-01T00:00:00 : '); + + expect(timeRangeQuery()).toEqual([ + { timeRange: '2025-05-01T00:00:00 : ' }, + { timeRange: 'today : now' }, + ]); + expect(result).toEqual({ + range: { since: '2025-05-01T00:00:00', until: null }, + today: '2025-05-31T00:00:00', + now: '2025-05-31T14:30:00', + shifts: [], + }); +}); + +test('fetchTimeRanges returns the server message when the call fails', async () => { + fetchMock.get('glob:*/api/v1/time_range/?q=*', { + status: 400, + body: { + message: 'Unexpected time range: From date cannot be larger than to date', + }, + }); + + const result = await fetchTimeRanges( + '2025-06-01T00:00:00 : 2025-05-01T00:00:00', + ['1 year ago'], + ); + + expect(result).toEqual({ + error: 'Unexpected time range: From date cannot be larger than to date', + status: 400, + }); +}); + +test('formatResolvedTimeRange formats a resolved range as the "Actual time range" text', () => { + expect( + formatResolvedTimeRange({ + since: '2025-05-24T00:00:00', + until: '2025-05-31T00:00:00', + }), + ).toBe('2025-05-24 ≤ col < 2025-05-31'); + expect( + formatResolvedTimeRange({ since: '2025-01-01T00:00:00', until: null }), + ).toBe('2025-01-01 ≤ col < ∞'); + expect(formatResolvedTimeRange({ since: null, until: null })).toBe( + '-∞ ≤ col < ∞', + ); + expect( + formatResolvedTimeRange({ + since: '2025-05-31T08:30:00', + until: '2025-05-31T14:30:00', + }), + ).toBe('2025-05-31T08:30:00 ≤ col < 2025-05-31T14:30:00'); +}); + +test('formatResolvedTimeRange applies an optional date format', () => { + expect( + formatResolvedTimeRange( + { since: '2025-05-24T00:00:00', until: '2025-05-31T00:00:00' }, + '%d-%m-%Y', + ), + ).toBe('24-05-2025 ≤ col < 31-05-2025'); + expect( + formatResolvedTimeRange( + { since: '2025-01-01T00:00:00', until: null }, + '%d-%m-%Y', + ), + ).toBe('01-01-2025 ≤ col < ∞'); +}); + +test('fetchTimeRangeList resolves every range and the server clock in one request', async () => { + fetchMock.get('glob:*/api/v1/time_range/?q=*', { + result: [ + { + since: '2024-05-31T00:00:00', + until: '2025-05-31T00:00:00', + timeRange: 'Last year', + shift: null, + }, + { + since: '2025-05-26T00:00:00', + until: '2025-06-02T00:00:00', + timeRange: 'Current week', + shift: null, + }, + { + since: '2025-05-31T00:00:00', + until: '2025-05-31T14:30:00', + timeRange: 'today : now', + shift: null, + }, + ], + }); + + const result = await fetchTimeRangeList(['Last year', 'Current week']); + + expect(fetchMock.callHistory.calls()).toHaveLength(1); + expect(timeRangeQuery()).toEqual([ + { timeRange: 'Last year' }, + { timeRange: 'Current week' }, + { timeRange: 'today : now' }, + ]); + expect(result).toEqual({ + today: '2025-05-31T00:00:00', + now: '2025-05-31T14:30:00', + ranges: [ + { since: '2024-05-31T00:00:00', until: '2025-05-31T00:00:00' }, + { since: '2025-05-26T00:00:00', until: '2025-06-02T00:00:00' }, + ], + }); +}); + +test('fetchTimeRangeList returns the server message when the call fails', async () => { + fetchMock.get('glob:*/api/v1/time_range/?q=*', { + status: 400, + body: { message: 'Unexpected time range: banana' }, + }); + + expect(await fetchTimeRangeList(['Last year'])).toEqual({ + error: 'Unexpected time range: banana', + status: 400, + }); +}); + +test('fetchTimeRanges and fetchTimeRangeList pass the request options on, and none by default', async () => { + fetchMock.get('glob:*/api/v1/time_range/?q=*', { result: [] }); + const get = jest.spyOn(SupersetClient, 'get'); + const options = { timeout: 10000, fetchRetryOptions: { retries: 0 } }; + + await fetchTimeRanges('Last week'); + await fetchTimeRangeList(['Last week']); + await fetchTimeRanges('Last week', [], options); + await fetchTimeRangeList(['Last week'], options); + + const sent = get.mock.calls.map(([request]) => request); + expect(sent.map(request => Object.keys(request))).toEqual([ + ['endpoint'], + ['endpoint'], + ['endpoint', 'timeout', 'fetchRetryOptions'], + ['endpoint', 'timeout', 'fetchRetryOptions'], + ]); + expect(sent[2]).toMatchObject(options); + expect(sent[3]).toMatchObject(options); + get.mockRestore(); +}); + +test('fetchTimeRanges and fetchTimeRangeList flag a timeout, and no other failure', async () => { + const get = jest.spyOn(SupersetClient, 'get').mockRejectedValue({ + error: 'Request timed out', + statusText: 'timeout', + timeout: 10000, + }); + + expect(await fetchTimeRanges('Last week')).toEqual({ + error: 'Request timed out', + timedOut: true, + }); + expect(await fetchTimeRangeList(['Last week'])).toEqual({ + error: 'Request timed out', + timedOut: true, + }); + + get.mockRejectedValue({ error: 'Bad gateway', statusText: 'Bad Gateway' }); + + expect(await fetchTimeRanges('Last week')).toEqual({ error: 'Bad Gateway' }); + get.mockRestore(); +}); + +test('fetchTimeRanges and fetchTimeRangeList pass the HTTP status of a failure on', async () => { + const get = jest.spyOn(SupersetClient, 'get').mockRejectedValue({ + error: 'Service Unavailable', + statusText: 'Service Unavailable', + status: 503, + }); + + expect(await fetchTimeRanges('Last week')).toEqual({ + error: 'Service Unavailable', + status: 503, + }); + expect(await fetchTimeRangeList(['Last week'])).toEqual({ + error: 'Service Unavailable', + status: 503, + }); + get.mockRestore(); +}); diff --git a/superset-frontend/packages/superset-ui-core/test/utils/featureFlag.test.ts b/superset-frontend/packages/superset-ui-core/test/utils/featureFlag.test.ts index 6a6646c64467..453ef908789b 100644 --- a/superset-frontend/packages/superset-ui-core/test/utils/featureFlag.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/utils/featureFlag.test.ts @@ -89,3 +89,12 @@ test('returns true for set GranularExportControls feature flag', () => { }); expect(isFeatureEnabled(FeatureFlag.GranularExportControls)).toEqual(true); }); + +test('reads the time range limits flag from the bootstrap data', () => { + Object.defineProperty(window, 'featureFlags', { + value: undefined, + }); + initFeatureFlags({ TIME_RANGE_LIMITS: true }); + expect(FeatureFlag.TimeRangeLimits).toBe('TIME_RANGE_LIMITS'); + expect(isFeatureEnabled(FeatureFlag.TimeRangeLimits)).toBe(true); +}); diff --git a/superset-frontend/spec/fixtures/dateRangeParity.json b/superset-frontend/spec/fixtures/dateRangeParity.json new file mode 100644 index 000000000000..4c134adf1b25 --- /dev/null +++ b/superset-frontend/spec/fixtures/dateRangeParity.json @@ -0,0 +1,2411 @@ +{ + "about": "Time range limits parity cases, read by tests/unit_tests/utils/test_date_range_parity.py (server) and dateRangeParity.test.ts (browser). Each case gives the policy levels in level order (global, dataset, dashboard), the server's now, and the resolved since/until (what /api/v1/time_range/ returns), all naive server wall-clock times. zone is a label for the reader only: neither side takes a zone. expected.outcome is pass, no_limits, invalid_policy, since_after_until or failure (the first broken rule). A tie case gives the dashboard payload (global, dashboard, datasets) instead of levels: the browser builds its levels with dashboardLimitLevels, the server orders them as get_effective_time_range_limits does.", + "cases": [ + { + "id": "Last 3 months", + "time_range": "Last 3 months", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 3, + "time_grain": "P1M" + }, + "max_look_back": { + "quantity": 13, + "time_grain": "P1M" + }, + "max_look_forward": { + "quantity": 0, + "time_grain": "P1D" + } + } + ] + ], + "since": "2025-02-28T00:00:00", + "until": "2025-05-31T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_SPAN_EXCEEDED", + "rule": "max_span", + "source": "global", + "limit": { + "quantity": 3, + "time_grain": "P1M" + }, + "message": "Range can't be longer than 3 months. This one is 3 months and 3 days." + } + }, + { + "id": "Last 3 months, 1 year ago", + "time_range": "Last 3 months", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 3, + "time_grain": "P1M" + }, + "max_look_back": { + "quantity": 13, + "time_grain": "P1M" + }, + "max_look_forward": { + "quantity": 0, + "time_grain": "P1D" + } + } + ] + ], + "since": "2024-02-28T00:00:00", + "until": "2024-05-31T00:00:00", + "time_offset": "1 year ago", + "expected": { + "outcome": "failure", + "reason_code": "MAX_SPAN_EXCEEDED", + "rule": "max_span", + "source": "global", + "limit": { + "quantity": 3, + "time_grain": "P1M" + }, + "message": "Time comparison \"1 year ago\": Range can't be longer than 3 months. This one is 3 months and 3 days." + } + }, + { + "id": "Current quarter", + "time_range": "Current quarter", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 3, + "time_grain": "P1M" + }, + "max_look_back": { + "quantity": 13, + "time_grain": "P1M" + }, + "max_look_forward": { + "quantity": 0, + "time_grain": "P1D" + } + } + ] + ], + "since": "2025-04-01T00:00:00", + "until": "2025-07-01T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_FORWARD_EXCEEDED", + "rule": "max_look_forward", + "source": "global", + "limit": { + "quantity": 0, + "time_grain": "P1D" + }, + "message": "End can't be later than today." + } + }, + { + "id": "previous calendar quarter", + "time_range": "previous calendar quarter", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 3, + "time_grain": "P1M" + }, + "max_look_back": { + "quantity": 13, + "time_grain": "P1M" + }, + "max_look_forward": { + "quantity": 0, + "time_grain": "P1D" + } + } + ] + ], + "since": "2025-01-01T00:00:00", + "until": "2025-04-01T00:00:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "sub-day 08:30:00", + "time_range": "2025-05-31T08:30:00 : now", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 6, + "time_grain": "PT1H" + }, + "max_look_forward": null + } + ] + ], + "since": "2025-05-31T08:30:00", + "until": "2025-05-31T14:30:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "sub-day 08:29:59", + "time_range": "2025-05-31T08:29:59 : now", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 6, + "time_grain": "PT1H" + }, + "max_look_forward": null + } + ] + ], + "since": "2025-05-31T08:29:59", + "until": "2025-05-31T14:30:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "sub-day 08:29:58", + "time_range": "2025-05-31T08:29:58 : now", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 6, + "time_grain": "PT1H" + }, + "max_look_forward": null + } + ] + ], + "since": "2025-05-31T08:29:58", + "until": "2025-05-31T14:30:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_BACK_EXCEEDED", + "rule": "max_look_back", + "source": "global", + "limit": { + "quantity": 6, + "time_grain": "PT1H" + }, + "message": "Start can't be earlier than May 31, 2025 08:30 (6 hours ago)." + } + }, + { + "id": "tie 3 x 13 x P1M", + "time_range": "2023-01-01T00:00:00 : 2025-05-01T00:00:00", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 13, + "time_grain": "P1M" + }, + "max_look_forward": null + } + ], + [ + "dataset", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 13, + "time_grain": "P1M" + }, + "max_look_forward": null + } + ], + [ + "dashboard", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 13, + "time_grain": "P1M" + }, + "max_look_forward": null + } + ] + ], + "since": "2023-01-01T00:00:00", + "until": "2025-05-01T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_BACK_EXCEEDED", + "rule": "max_look_back", + "source": "global", + "limit": { + "quantity": 13, + "time_grain": "P1M" + }, + "message": "Start can't be earlier than Apr 30, 2024 (13 months ago)." + } + }, + { + "id": "12 x P1M vs 1 x P1Y", + "time_range": "2023-01-01T00:00:00 : 2025-05-01T00:00:00", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 12, + "time_grain": "P1M" + }, + "max_look_forward": null + } + ], + [ + "dashboard", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 1, + "time_grain": "P1Y" + }, + "max_look_forward": null + } + ] + ], + "since": "2023-01-01T00:00:00", + "until": "2025-05-01T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_BACK_EXCEEDED", + "rule": "max_look_back", + "source": "global", + "limit": { + "quantity": 12, + "time_grain": "P1M" + }, + "message": "Start can't be earlier than May 31, 2024 (12 months ago)." + } + }, + { + "id": "dataset 6 x P1M", + "time_range": "2023-01-01T00:00:00 : 2025-05-01T00:00:00", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 13, + "time_grain": "P1M" + }, + "max_look_forward": null + } + ], + [ + "dataset", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 6, + "time_grain": "P1M" + }, + "max_look_forward": null + } + ], + [ + "dashboard", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 13, + "time_grain": "P1M" + }, + "max_look_forward": null + } + ] + ], + "since": "2023-01-01T00:00:00", + "until": "2025-05-01T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_BACK_EXCEEDED", + "rule": "max_look_back", + "source": "dataset", + "limit": { + "quantity": 6, + "time_grain": "P1M" + }, + "message": "Start can't be earlier than Nov 30, 2024 (6 months ago)." + } + }, + { + "id": "spans 90 x P1D + 3 x P1M", + "time_range": "Last 3 months", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 90, + "time_grain": "P1D" + }, + "max_look_back": null, + "max_look_forward": null + } + ], + [ + "dashboard", + { + "schema_version": 1, + "max_span": { + "quantity": 3, + "time_grain": "P1M" + }, + "max_look_back": null, + "max_look_forward": null + } + ] + ], + "since": "2025-02-28T00:00:00", + "until": "2025-05-31T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_SPAN_EXCEEDED", + "rule": "max_span", + "source": "dashboard", + "limit": { + "quantity": 3, + "time_grain": "P1M" + }, + "message": "Range can't be longer than 3 months. This one is 3 months and 3 days." + } + }, + { + "id": "3 months back, May 31", + "time_range": "2025-02-27T00:00:00 : 2025-03-01T00:00:00", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 3, + "time_grain": "P1M" + }, + "max_look_forward": null + } + ] + ], + "since": "2025-02-27T00:00:00", + "until": "2025-03-01T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_BACK_EXCEEDED", + "rule": "max_look_back", + "source": "global", + "limit": { + "quantity": 3, + "time_grain": "P1M" + }, + "message": "Start can't be earlier than Feb 28, 2025 (3 months ago)." + } + }, + { + "id": "1 year back, Feb 29", + "time_range": "2023-02-27T00:00:00 : 2023-03-01T00:00:00", + "zone": "UTC", + "now": "2024-02-29T10:00:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 1, + "time_grain": "P1Y" + }, + "max_look_forward": null + } + ] + ], + "since": "2023-02-27T00:00:00", + "until": "2023-03-01T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_BACK_EXCEEDED", + "rule": "max_look_back", + "source": "global", + "limit": { + "quantity": 1, + "time_grain": "P1Y" + }, + "message": "Start can't be earlier than Feb 28, 2023 (1 year ago)." + } + }, + { + "id": "open end", + "time_range": "2025-05-01T00:00:00 : ", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 3, + "time_grain": "P1M" + }, + "max_look_back": { + "quantity": 13, + "time_grain": "P1M" + }, + "max_look_forward": { + "quantity": 0, + "time_grain": "P1D" + } + } + ] + ], + "since": "2025-05-01T00:00:00", + "until": null, + "expected": { + "outcome": "failure", + "reason_code": "UNBOUNDED_RANGE", + "rule": null, + "source": null, + "limit": null, + "message": "An end date is required." + } + }, + { + "id": "open end + dashboard", + "time_range": "2025-05-01T00:00:00 : ", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 3, + "time_grain": "P1M" + }, + "max_look_back": { + "quantity": 13, + "time_grain": "P1M" + }, + "max_look_forward": { + "quantity": 0, + "time_grain": "P1D" + } + } + ] + ], + "since": "2025-05-01T00:00:00", + "until": null, + "dashboard_id": 7, + "expected": { + "outcome": "failure", + "reason_code": "UNBOUNDED_RANGE", + "rule": null, + "source": null, + "limit": null, + "message": "This chart requires a bounded time range. Ask a dashboard editor to add or scope a time range filter." + } + }, + { + "id": "No filter", + "time_range": "No filter", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 3, + "time_grain": "P1M" + }, + "max_look_back": { + "quantity": 13, + "time_grain": "P1M" + }, + "max_look_forward": { + "quantity": 0, + "time_grain": "P1D" + } + } + ] + ], + "since": null, + "until": null, + "expected": { + "outcome": "failure", + "reason_code": "UNBOUNDED_RANGE", + "rule": null, + "source": null, + "limit": null, + "message": "Choose a start and an end date. Time range limits are on." + } + }, + { + "id": "NO_VALID closed", + "time_range": "2025-05-31T10:00:00 : 2025-05-31T12:00:00", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 0, + "time_grain": "PT1H" + }, + "max_look_forward": null + } + ], + [ + "dashboard", + { + "schema_version": 1, + "max_span": null, + "max_look_back": null, + "max_look_forward": { + "quantity": 0, + "time_grain": "PT1H" + } + } + ] + ], + "since": "2025-05-31T10:00:00", + "until": "2025-05-31T12:00:00", + "expected": { + "outcome": "failure", + "reason_code": "NO_VALID_RANGE", + "rule": null, + "source": null, + "limit": null, + "message": "No time range fits the limits: the earliest start (now) is not before the latest end (now). Ask an editor or admin to fix the limits." + } + }, + { + "id": "NO_VALID open end", + "time_range": "2025-05-31T10:00:00 : ", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 0, + "time_grain": "PT1H" + }, + "max_look_forward": null + } + ], + [ + "dashboard", + { + "schema_version": 1, + "max_span": null, + "max_look_back": null, + "max_look_forward": { + "quantity": 0, + "time_grain": "PT1H" + } + } + ] + ], + "since": "2025-05-31T10:00:00", + "until": null, + "expected": { + "outcome": "failure", + "reason_code": "NO_VALID_RANGE", + "rule": null, + "source": null, + "limit": null, + "message": "No time range fits the limits: the earliest start (now) is not before the latest end (now). Ask an editor or admin to fix the limits." + } + }, + { + "id": "NO_VALID No filter", + "time_range": "No filter", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 0, + "time_grain": "PT1H" + }, + "max_look_forward": null + } + ], + [ + "dashboard", + { + "schema_version": 1, + "max_span": null, + "max_look_back": null, + "max_look_forward": { + "quantity": 0, + "time_grain": "PT1H" + } + } + ] + ], + "since": null, + "until": null, + "expected": { + "outcome": "failure", + "reason_code": "NO_VALID_RANGE", + "rule": null, + "source": null, + "limit": null, + "message": "No time range fits the limits: the earliest start (now) is not before the latest end (now). Ask an editor or admin to fix the limits." + } + }, + { + "id": "start after end (resolved dates swapped)", + "time_range": "2025-05-02T00:00:00 : 2025-05-01T00:00:00", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 3, + "time_grain": "P1M" + }, + "max_look_back": { + "quantity": 13, + "time_grain": "P1M" + }, + "max_look_forward": { + "quantity": 0, + "time_grain": "P1D" + } + } + ] + ], + "since": "2025-05-02T00:00:00", + "until": "2025-05-01T00:00:00", + "expected": { + "outcome": "since_after_until" + } + }, + { + "id": "no limits, No filter", + "time_range": "No filter", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": null, + "max_look_forward": null + } + ], + [ + "dataset", + { + "schema_version": 1, + "max_span": null, + "max_look_back": null, + "max_look_forward": null + } + ], + [ + "dashboard", + { + "schema_version": 1, + "max_span": null, + "max_look_back": null, + "max_look_forward": null + } + ] + ], + "since": null, + "until": null, + "expected": { + "outcome": "no_limits" + } + }, + { + "id": "PT0.5H look-back", + "time_range": "2025-05-31T13:00:00 : now", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 1, + "time_grain": "PT0.5H" + }, + "max_look_forward": null + } + ] + ], + "since": "2025-05-31T13:00:00", + "until": "2025-05-31T14:30:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_BACK_EXCEEDED", + "rule": "max_look_back", + "source": "global", + "limit": { + "quantity": 1, + "time_grain": "PT0.5H" + }, + "message": "Start can't be earlier than May 31, 2025 14:00 (30 minutes ago)." + } + }, + { + "id": "P0.25Y span, 1 year ago", + "time_range": "Last 3 months", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 1, + "time_grain": "P0.25Y" + }, + "max_look_back": null, + "max_look_forward": null + } + ] + ], + "since": "2024-02-28T00:00:00", + "until": "2024-05-31T00:00:00", + "time_offset": "1 year ago", + "expected": { + "outcome": "failure", + "reason_code": "MAX_SPAN_EXCEEDED", + "rule": "max_span", + "source": "global", + "limit": { + "quantity": 1, + "time_grain": "P0.25Y" + }, + "message": "Time comparison \"1 year ago\": Range can't be longer than 1 quarter. This one is 1 quarter and 3 days." + } + }, + { + "id": "P3M span", + "time_range": "2025-01-01T00:00:00 : 2025-05-31T00:00:00", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 1, + "time_grain": "P3M" + }, + "max_look_back": null, + "max_look_forward": null + } + ] + ], + "since": "2025-01-01T00:00:00", + "until": "2025-05-31T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_SPAN_EXCEEDED", + "rule": "max_span", + "source": "global", + "limit": { + "quantity": 1, + "time_grain": "P3M" + }, + "message": "Range can't be longer than 1 quarter. This one is 1 quarter and 60 days." + } + }, + { + "id": "P3M look-back x 2", + "time_range": "2024-11-01T00:00:00 : today", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 2, + "time_grain": "P3M" + }, + "max_look_forward": null + } + ] + ], + "since": "2024-11-01T00:00:00", + "until": "2025-05-31T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_BACK_EXCEEDED", + "rule": "max_look_back", + "source": "global", + "limit": { + "quantity": 2, + "time_grain": "P3M" + }, + "message": "Start can't be earlier than Nov 30, 2024 (2 quarters ago)." + } + }, + { + "id": "unknown grain P1Q", + "time_range": "Last 3 months", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 1, + "time_grain": "P1Q" + }, + "max_look_back": null, + "max_look_forward": null + } + ] + ], + "since": "2025-02-28T00:00:00", + "until": "2025-05-31T00:00:00", + "expected": { + "outcome": "invalid_policy" + } + }, + { + "id": "month end, 3 months back passes at the clamped bound", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 3, + "time_grain": "P1M" + }, + "max_look_forward": null + } + ] + ], + "since": "2025-02-28T00:00:00", + "until": "2025-05-31T00:00:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "leap day, 1 year back passes at the clamped bound", + "zone": "UTC", + "now": "2024-02-29T10:00:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 1, + "time_grain": "P1Y" + }, + "max_look_forward": null + } + ] + ], + "since": "2023-02-28T00:00:00", + "until": "2024-02-29T00:00:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "month end, 1 month span from Mar 31", + "note": "month-end span", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 1, + "time_grain": "P1M" + }, + "max_look_back": null, + "max_look_forward": null + } + ] + ], + "since": "2025-02-28T00:00:00", + "until": "2025-03-31T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_SPAN_EXCEEDED", + "rule": "max_span", + "source": "global", + "limit": { + "quantity": 1, + "time_grain": "P1M" + }, + "message": "Range can't be longer than 1 month. This one is 1 month and 3 days." + } + }, + { + "id": "month end, 1 month span from Feb 27 fails", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 1, + "time_grain": "P1M" + }, + "max_look_back": null, + "max_look_forward": null + } + ] + ], + "since": "2025-02-27T00:00:00", + "until": "2025-03-31T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_SPAN_EXCEEDED", + "rule": "max_span", + "source": "global", + "limit": { + "quantity": 1, + "time_grain": "P1M" + }, + "message": "Range can't be longer than 1 month. This one is 1 month and 4 days." + } + }, + { + "id": "zero look-back, start today passes", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 0, + "time_grain": "P1D" + }, + "max_look_forward": null + } + ] + ], + "since": "2025-05-31T00:00:00", + "until": "2025-05-31T14:00:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "zero look-back, start yesterday fails", + "note": "zero look-back", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 0, + "time_grain": "P1D" + }, + "max_look_forward": null + } + ] + ], + "since": "2025-05-30T00:00:00", + "until": "2025-05-31T14:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_BACK_EXCEEDED", + "rule": "max_look_back", + "source": "global", + "limit": { + "quantity": 0, + "time_grain": "P1D" + }, + "message": "Start can't be earlier than today." + } + }, + { + "id": "zero look-forward, end tomorrow midnight passes", + "note": "zero look-forward", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": null, + "max_look_forward": { + "quantity": 0, + "time_grain": "P1D" + } + } + ] + ], + "since": "2025-05-30T00:00:00", + "until": "2025-06-01T00:00:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "zero look-forward in hours, end one second past now passes", + "note": "zero look-forward, sub-day slack", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": null, + "max_look_forward": { + "quantity": 0, + "time_grain": "PT1H" + } + } + ] + ], + "since": "2025-05-31T00:00:00", + "until": "2025-05-31T14:30:01", + "expected": { + "outcome": "pass" + } + }, + { + "id": "zero look-forward in hours, end two seconds past now fails", + "note": "zero look-forward, sub-day slack", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": null, + "max_look_forward": { + "quantity": 0, + "time_grain": "PT1H" + } + } + ] + ], + "since": "2025-05-31T00:00:00", + "until": "2025-05-31T14:30:02", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_FORWARD_EXCEEDED", + "rule": "max_look_forward", + "source": "global", + "limit": { + "quantity": 0, + "time_grain": "PT1H" + }, + "message": "End can't be later than now." + } + }, + { + "id": "sub-day span of exactly 6 hours passes", + "note": "sub-day span: the slack is on the bounds only", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 6, + "time_grain": "PT1H" + }, + "max_look_back": null, + "max_look_forward": null + } + ] + ], + "since": "2025-05-31T08:30:00", + "until": "2025-05-31T14:30:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "sub-day span one second over fails", + "note": "sub-day span: the slack is on the bounds only", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 6, + "time_grain": "PT1H" + }, + "max_look_back": null, + "max_look_forward": null + } + ] + ], + "since": "2025-05-31T08:29:59", + "until": "2025-05-31T14:30:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_SPAN_EXCEEDED", + "rule": "max_span", + "source": "global", + "limit": { + "quantity": 6, + "time_grain": "PT1H" + }, + "message": "Range can't be longer than 6 hours. This one is 7 hours." + } + }, + { + "id": "spring, 6 hours back, start 02:00 passes", + "note": "Limits dst_spring_hours_back_wall_clock", + "zone": "Europe/Warsaw", + "now": "2026-03-29T08:00:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 6, + "time_grain": "PT1H" + }, + "max_look_forward": null + } + ] + ], + "since": "2026-03-29T02:00:00", + "until": "2026-03-29T08:00:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "spring, 6 hours back, start 01:59:58 fails", + "note": "Limits dst_spring_hours_back_wall_clock", + "zone": "Europe/Warsaw", + "now": "2026-03-29T08:00:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 6, + "time_grain": "PT1H" + }, + "max_look_forward": null + } + ] + ], + "since": "2026-03-29T01:59:58", + "until": "2026-03-29T08:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_BACK_EXCEEDED", + "rule": "max_look_back", + "source": "global", + "limit": { + "quantity": 6, + "time_grain": "PT1H" + }, + "message": "Start can't be earlier than Mar 29, 2026 02:00 (6 hours ago)." + } + }, + { + "id": "autumn, 6 hours back, start 23:00 the day before passes", + "note": "Limits dst_autumn_hours_back_wall_clock", + "zone": "Europe/Warsaw", + "now": "2026-10-25T05:00:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 6, + "time_grain": "PT1H" + }, + "max_look_forward": null + } + ] + ], + "since": "2026-10-24T23:00:00", + "until": "2026-10-25T05:00:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "autumn, 6 hours back, start 22:59:58 the day before fails", + "note": "Limits dst_autumn_hours_back_wall_clock", + "zone": "Europe/Warsaw", + "now": "2026-10-25T05:00:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 6, + "time_grain": "PT1H" + }, + "max_look_forward": null + } + ] + ], + "since": "2026-10-24T22:59:58", + "until": "2026-10-25T05:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_BACK_EXCEEDED", + "rule": "max_look_back", + "source": "global", + "limit": { + "quantity": 6, + "time_grain": "PT1H" + }, + "message": "Start can't be earlier than Oct 24, 2026 23:00 (6 hours ago)." + } + }, + { + "id": "spring, 2 hours forward, end 02:30 passes", + "note": "Limits dst_spring_hours_forward_wall_clock", + "zone": "Europe/Warsaw", + "now": "2026-03-29T00:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": null, + "max_look_forward": { + "quantity": 2, + "time_grain": "PT1H" + } + } + ] + ], + "since": "2026-03-29T00:00:00", + "until": "2026-03-29T02:30:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "spring, 2 hours forward, end 02:30:02 fails", + "note": "Limits dst_spring_hours_forward_wall_clock", + "zone": "Europe/Warsaw", + "now": "2026-03-29T00:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": null, + "max_look_forward": { + "quantity": 2, + "time_grain": "PT1H" + } + } + ] + ], + "since": "2026-03-29T00:00:00", + "until": "2026-03-29T02:30:02", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_FORWARD_EXCEEDED", + "rule": "max_look_forward", + "source": "global", + "limit": { + "quantity": 2, + "time_grain": "PT1H" + }, + "message": "End can't be later than Mar 29, 2026 02:30 (2 hours from now)." + } + }, + { + "id": "spring, 1 day back from 14:30, start Mar 28 passes", + "zone": "Europe/Warsaw", + "now": "2026-03-29T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 1, + "time_grain": "P1D" + }, + "max_look_forward": null + } + ] + ], + "since": "2026-03-28T00:00:00", + "until": "2026-03-29T14:00:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "spring, 1 day back from 14:30, start Mar 27 23:00 fails", + "note": "Limits dst_spring_day_back_midnight", + "zone": "Europe/Warsaw", + "now": "2026-03-29T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 1, + "time_grain": "P1D" + }, + "max_look_forward": null + } + ] + ], + "since": "2026-03-27T23:00:00", + "until": "2026-03-29T14:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_BACK_EXCEEDED", + "rule": "max_look_back", + "source": "global", + "limit": { + "quantity": 1, + "time_grain": "P1D" + }, + "message": "Start can't be earlier than Mar 28, 2026 (1 day ago)." + } + }, + { + "id": "spring, 1 day back from Mar 30, start Mar 29 passes", + "zone": "Europe/Warsaw", + "now": "2026-03-30T10:00:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 1, + "time_grain": "P1D" + }, + "max_look_forward": null + } + ] + ], + "since": "2026-03-29T00:00:00", + "until": "2026-03-30T10:00:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "spring, 1 day back from Mar 30, start Mar 28 23:00 fails", + "note": "Limits dst_spring_day_back_across_the_change", + "zone": "Europe/Warsaw", + "now": "2026-03-30T10:00:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 1, + "time_grain": "P1D" + }, + "max_look_forward": null + } + ] + ], + "since": "2026-03-28T23:00:00", + "until": "2026-03-30T10:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_BACK_EXCEEDED", + "rule": "max_look_back", + "source": "global", + "limit": { + "quantity": 1, + "time_grain": "P1D" + }, + "message": "Start can't be earlier than Mar 29, 2026 (1 day ago)." + } + }, + { + "id": "spring, 1 day span over the 23-hour day passes", + "note": "Limits dst_spring_one_calendar_day", + "zone": "Europe/Warsaw", + "now": "2026-03-30T12:00:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 1, + "time_grain": "P1D" + }, + "max_look_back": null, + "max_look_forward": null + } + ] + ], + "since": "2026-03-29T00:00:00", + "until": "2026-03-30T00:00:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "spring, 1 day span to 01:00 the next day fails", + "note": "Limits dst_spring_one_calendar_day", + "zone": "Europe/Warsaw", + "now": "2026-03-30T12:00:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 1, + "time_grain": "P1D" + }, + "max_look_back": null, + "max_look_forward": null + } + ] + ], + "since": "2026-03-29T00:00:00", + "until": "2026-03-30T01:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_SPAN_EXCEEDED", + "rule": "max_span", + "source": "global", + "limit": { + "quantity": 1, + "time_grain": "P1D" + }, + "message": "Range can't be longer than 1 day. This one is 2 days." + } + }, + { + "id": "autumn, 1 day span over the 25-hour day passes", + "note": "Limits dst_autumn_one_calendar_day", + "zone": "Europe/Warsaw", + "now": "2026-10-26T12:00:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 1, + "time_grain": "P1D" + }, + "max_look_back": null, + "max_look_forward": null + } + ] + ], + "since": "2026-10-25T00:00:00", + "until": "2026-10-26T00:00:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "autumn, 1 day span to 01:00 the next day fails", + "note": "Limits dst_autumn_one_calendar_day", + "zone": "Europe/Warsaw", + "now": "2026-10-26T12:00:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 1, + "time_grain": "P1D" + }, + "max_look_back": null, + "max_look_forward": null + } + ] + ], + "since": "2026-10-25T00:00:00", + "until": "2026-10-26T01:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_SPAN_EXCEEDED", + "rule": "max_span", + "source": "global", + "limit": { + "quantity": 1, + "time_grain": "P1D" + }, + "message": "Range can't be longer than 1 day. This one is 2 days." + } + }, + { + "id": "dataset look-back, dashboard look-forward, start too early", + "note": "the failure names the look-back's level", + "time_range": "2024-01-01T00:00:00 : 2025-05-01T00:00:00", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "dataset", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 6, + "time_grain": "P1M" + }, + "max_look_forward": null + } + ], + [ + "dashboard", + { + "schema_version": 1, + "max_span": null, + "max_look_back": null, + "max_look_forward": { + "quantity": 0, + "time_grain": "P1D" + } + } + ] + ], + "since": "2024-01-01T00:00:00", + "until": "2025-05-01T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_BACK_EXCEEDED", + "rule": "max_look_back", + "source": "dataset", + "limit": { + "quantity": 6, + "time_grain": "P1M" + }, + "message": "Start can't be earlier than Nov 30, 2024 (6 months ago)." + } + }, + { + "id": "dashboard look-back, dataset look-forward, start too early", + "note": "the failure names the look-back's level", + "time_range": "2024-01-01T00:00:00 : 2025-05-01T00:00:00", + "zone": "UTC", + "now": "2025-05-31T14:30:00", + "levels": [ + [ + "dataset", + { + "schema_version": 1, + "max_span": null, + "max_look_back": null, + "max_look_forward": { + "quantity": 0, + "time_grain": "P1D" + } + } + ], + [ + "dashboard", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 6, + "time_grain": "P1M" + }, + "max_look_forward": null + } + ] + ], + "since": "2024-01-01T00:00:00", + "until": "2025-05-01T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_BACK_EXCEEDED", + "rule": "max_look_back", + "source": "dashboard", + "limit": { + "quantity": 6, + "time_grain": "P1M" + }, + "message": "Start can't be earlier than Nov 30, 2024 (6 months ago)." + } + }, + { + "id": "span tie dataset=dashboard 30 d", + "note": "dashboard payload, server level order", + "zone": "UTC", + "now": "2026-10-30T12:00:00", + "payload": { + "global": null, + "dashboard": { + "schema_version": 1, + "max_span": { + "quantity": 30, + "time_grain": "P1D" + }, + "max_look_back": null, + "max_look_forward": null + }, + "datasets": [ + { + "id": 7, + "name": "t", + "policy": { + "schema_version": 1, + "max_span": { + "quantity": 30, + "time_grain": "P1D" + }, + "max_look_back": null, + "max_look_forward": null + } + } + ] + }, + "since": "2026-09-15T00:00:00", + "until": "2026-10-30T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_SPAN_EXCEEDED", + "rule": "max_span", + "source": "dataset", + "limit": { + "quantity": 30, + "time_grain": "P1D" + }, + "message": "Range can't be longer than 30 days. This one is 45 days." + } + }, + { + "id": "look-back tie 30 d", + "note": "dashboard payload, server level order", + "zone": "UTC", + "now": "2026-10-30T12:00:00", + "payload": { + "global": null, + "dashboard": { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 30, + "time_grain": "P1D" + }, + "max_look_forward": null + }, + "datasets": [ + { + "id": 7, + "name": "t", + "policy": { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 30, + "time_grain": "P1D" + }, + "max_look_forward": null + } + } + ] + }, + "since": "2026-09-01T00:00:00", + "until": "2026-10-30T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_BACK_EXCEEDED", + "rule": "max_look_back", + "source": "dataset", + "limit": { + "quantity": 30, + "time_grain": "P1D" + }, + "message": "Start can't be earlier than Sep 30, 2026 (30 days ago)." + } + }, + { + "id": "look-forward tie 1 d", + "note": "dashboard payload, server level order", + "zone": "UTC", + "now": "2026-10-30T12:00:00", + "payload": { + "global": null, + "dashboard": { + "schema_version": 1, + "max_span": null, + "max_look_back": null, + "max_look_forward": { + "quantity": 1, + "time_grain": "P1D" + } + }, + "datasets": [ + { + "id": 7, + "name": "t", + "policy": { + "schema_version": 1, + "max_span": null, + "max_look_back": null, + "max_look_forward": { + "quantity": 1, + "time_grain": "P1D" + } + } + } + ] + }, + "since": "2026-10-30T00:00:00", + "until": "2026-11-05T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_FORWARD_EXCEEDED", + "rule": "max_look_forward", + "source": "dataset", + "limit": { + "quantity": 1, + "time_grain": "P1D" + }, + "message": "End can't be later than Oct 31, 2026 (1 day from now)." + } + }, + { + "id": "span tie in other units (4 weeks vs 28 days)", + "note": "dashboard payload, server level order", + "zone": "UTC", + "now": "2026-10-30T12:00:00", + "payload": { + "global": null, + "dashboard": { + "schema_version": 1, + "max_span": { + "quantity": 28, + "time_grain": "P1D" + }, + "max_look_back": null, + "max_look_forward": null + }, + "datasets": [ + { + "id": 7, + "name": "t", + "policy": { + "schema_version": 1, + "max_span": { + "quantity": 4, + "time_grain": "P1W" + }, + "max_look_back": null, + "max_look_forward": null + } + } + ] + }, + "since": "2026-09-15T00:00:00", + "until": "2026-10-30T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_SPAN_EXCEEDED", + "rule": "max_span", + "source": "dataset", + "limit": { + "quantity": 4, + "time_grain": "P1W" + }, + "message": "Range can't be longer than 4 weeks. This one is 6 weeks and 3 days." + } + }, + { + "id": "control: dashboard stricter (20 d vs 30 d)", + "note": "dashboard payload, server level order", + "zone": "UTC", + "now": "2026-10-30T12:00:00", + "payload": { + "global": null, + "dashboard": { + "schema_version": 1, + "max_span": { + "quantity": 20, + "time_grain": "P1D" + }, + "max_look_back": null, + "max_look_forward": null + }, + "datasets": [ + { + "id": 7, + "name": "t", + "policy": { + "schema_version": 1, + "max_span": { + "quantity": 30, + "time_grain": "P1D" + }, + "max_look_back": null, + "max_look_forward": null + } + } + ] + }, + "since": "2026-09-15T00:00:00", + "until": "2026-10-30T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_SPAN_EXCEEDED", + "rule": "max_span", + "source": "dashboard", + "limit": { + "quantity": 20, + "time_grain": "P1D" + }, + "message": "Range can't be longer than 20 days. This one is 45 days." + } + }, + { + "id": "control: global ties too (global first both orders)", + "note": "dashboard payload, server level order", + "zone": "UTC", + "now": "2026-10-30T12:00:00", + "payload": { + "global": { + "schema_version": 1, + "max_span": { + "quantity": 30, + "time_grain": "P1D" + }, + "max_look_back": null, + "max_look_forward": null + }, + "dashboard": { + "schema_version": 1, + "max_span": { + "quantity": 30, + "time_grain": "P1D" + }, + "max_look_back": null, + "max_look_forward": null + }, + "datasets": [ + { + "id": 7, + "name": "t", + "policy": { + "schema_version": 1, + "max_span": { + "quantity": 30, + "time_grain": "P1D" + }, + "max_look_back": null, + "max_look_forward": null + } + } + ] + }, + "since": "2026-09-15T00:00:00", + "until": "2026-10-30T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_SPAN_EXCEEDED", + "rule": "max_span", + "source": "global", + "limit": { + "quantity": 30, + "time_grain": "P1D" + }, + "message": "Range can't be longer than 30 days. This one is 45 days." + } + }, + { + "id": "1 month span, Feb 28 to Mar 31 fails", + "time_range": "2026-02-28 : 2026-03-31", + "zone": "UTC", + "now": "2026-04-15T10:00:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 1, + "time_grain": "P1M" + }, + "max_look_back": { + "quantity": 13, + "time_grain": "P1M" + }, + "max_look_forward": { + "quantity": 0, + "time_grain": "P1D" + } + } + ] + ], + "since": "2026-02-28T00:00:00", + "until": "2026-03-31T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_SPAN_EXCEEDED", + "rule": "max_span", + "source": "global", + "limit": { + "quantity": 1, + "time_grain": "P1M" + }, + "message": "Range can't be longer than 1 month. This one is 1 month and 3 days." + } + }, + { + "id": "1 month span, Feb 28 to Mar 28 passes", + "time_range": "2026-02-28 : 2026-03-28", + "zone": "UTC", + "now": "2026-04-15T10:00:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 1, + "time_grain": "P1M" + }, + "max_look_back": { + "quantity": 13, + "time_grain": "P1M" + }, + "max_look_forward": { + "quantity": 0, + "time_grain": "P1D" + } + } + ] + ], + "since": "2026-02-28T00:00:00", + "until": "2026-03-28T00:00:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "Last month on Mar 31, 1 month span fails", + "time_range": "Last month", + "zone": "UTC", + "now": "2026-03-31T10:00:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": { + "quantity": 1, + "time_grain": "P1M" + }, + "max_look_back": { + "quantity": 13, + "time_grain": "P1M" + }, + "max_look_forward": { + "quantity": 0, + "time_grain": "P1D" + } + } + ] + ], + "since": "2026-02-28T00:00:00", + "until": "2026-03-31T00:00:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_SPAN_EXCEEDED", + "rule": "max_span", + "source": "global", + "limit": { + "quantity": 1, + "time_grain": "P1M" + }, + "message": "Range can't be longer than 1 month. This one is 1 month and 3 days." + } + }, + { + "id": "7 days back, start exactly at now minus 7 days passes", + "time_range": "DATEADD(DATETIME(\"now\"), -7, day) : now", + "zone": "UTC", + "now": "2026-11-03T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 7, + "time_grain": "P1D" + }, + "max_look_forward": { + "quantity": 0, + "time_grain": "P1D" + } + } + ] + ], + "since": "2026-10-27T14:30:00", + "until": "2026-11-03T14:30:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "7 days back, start one second earlier passes", + "time_range": "2026-10-27T14:29:59 : now", + "zone": "UTC", + "now": "2026-11-03T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 7, + "time_grain": "P1D" + }, + "max_look_forward": { + "quantity": 0, + "time_grain": "P1D" + } + } + ] + ], + "since": "2026-10-27T14:29:59", + "until": "2026-11-03T14:30:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "7 days back, start one second before midnight 7 days ago fails", + "time_range": "2026-10-26T23:59:59 : now", + "zone": "UTC", + "now": "2026-11-03T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 7, + "time_grain": "P1D" + }, + "max_look_forward": { + "quantity": 0, + "time_grain": "P1D" + } + } + ] + ], + "since": "2026-10-26T23:59:59", + "until": "2026-11-03T14:30:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_BACK_EXCEEDED", + "rule": "max_look_back", + "source": "global", + "limit": { + "quantity": 7, + "time_grain": "P1D" + }, + "message": "Start can't be earlier than Oct 27, 2026 (7 days ago)." + } + }, + { + "id": "Last 7 days, 7 days back passes", + "time_range": "Last 7 days", + "zone": "UTC", + "now": "2026-11-03T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 7, + "time_grain": "P1D" + }, + "max_look_forward": { + "quantity": 0, + "time_grain": "P1D" + } + } + ] + ], + "since": "2026-10-27T00:00:00", + "until": "2026-11-03T00:00:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "Today, zero look-forward passes", + "time_range": "Today", + "zone": "UTC", + "now": "2026-11-03T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 7, + "time_grain": "P1D" + }, + "max_look_forward": { + "quantity": 0, + "time_grain": "P1D" + } + } + ] + ], + "since": "2026-11-03T00:00:00", + "until": "2026-11-04T00:00:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "Today, zero look-back passes", + "time_range": "Today", + "zone": "UTC", + "now": "2026-11-03T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 0, + "time_grain": "P1D" + }, + "max_look_forward": { + "quantity": 1, + "time_grain": "P1D" + } + } + ] + ], + "since": "2026-11-03T00:00:00", + "until": "2026-11-04T00:00:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "zero look-back, start at now passes", + "time_range": "now : tomorrow", + "zone": "UTC", + "now": "2026-11-03T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 0, + "time_grain": "P1D" + }, + "max_look_forward": { + "quantity": 1, + "time_grain": "P1D" + } + } + ] + ], + "since": "2026-11-03T14:30:00", + "until": "2026-11-04T00:00:00", + "expected": { + "outcome": "pass" + } + }, + { + "id": "zero look-back, start one second before midnight today fails", + "time_range": "2026-11-02T23:59:59 : now", + "zone": "UTC", + "now": "2026-11-03T14:30:00", + "levels": [ + [ + "global", + { + "schema_version": 1, + "max_span": null, + "max_look_back": { + "quantity": 0, + "time_grain": "P1D" + }, + "max_look_forward": { + "quantity": 1, + "time_grain": "P1D" + } + } + ] + ], + "since": "2026-11-02T23:59:59", + "until": "2026-11-03T14:30:00", + "expected": { + "outcome": "failure", + "reason_code": "MAX_LOOK_BACK_EXCEEDED", + "rule": "max_look_back", + "source": "global", + "limit": { + "quantity": 0, + "time_grain": "P1D" + }, + "message": "Start can't be earlier than today." + } + } + ] +} diff --git a/superset-frontend/spec/helpers/reducerIndex.ts b/superset-frontend/spec/helpers/reducerIndex.ts index 034305ca5a05..2d268102e86f 100644 --- a/superset-frontend/spec/helpers/reducerIndex.ts +++ b/superset-frontend/spec/helpers/reducerIndex.ts @@ -24,6 +24,7 @@ import dashboardFilters from 'src/dashboard/reducers/dashboardFilters'; import nativeFilters from 'src/dashboard/reducers/nativeFilters'; import datasources from 'src/dashboard/reducers/datasources'; import sliceEntities from 'src/dashboard/reducers/sliceEntities'; +import timeRangeLimits from 'src/dashboard/reducers/timeRangeLimits'; import dashboardLayout from 'src/dashboard/reducers/undoableDashboardLayout'; import messageToasts from 'src/components/MessageToasts/reducers'; import saveModal from 'src/explore/reducers/saveModalReducer'; @@ -56,6 +57,7 @@ export default { impressionId, messageToasts, sliceEntities, + timeRangeLimits, saveModal, explore, sqlLab, diff --git a/superset-frontend/src/components/Chart/chartAction.ts b/superset-frontend/src/components/Chart/chartAction.ts index 405ad136fa94..395f7da9d8d0 100644 --- a/superset-frontend/src/components/Chart/chartAction.ts +++ b/superset-frontend/src/components/Chart/chartAction.ts @@ -64,6 +64,12 @@ import type { Dispatch, Action, AnyAction } from 'redux'; import type { ThunkAction, ThunkDispatch } from 'redux-thunk'; import type { History } from 'history'; import type { ChartState } from 'src/explore/types'; +import type { TimeRangeLimitsState } from 'src/dashboard/reducers/timeRangeLimits'; +import { + chartHoldMode, + checkChartRanges, + takePrebuiltQueryContext, +} from 'src/dashboard/actions/timeRangeLimits'; // Types for the Redux state export interface ChartsState { @@ -97,6 +103,7 @@ export interface RootState { common: CommonState; dashboardInfo: DashboardInfoState; dataMask: DataMaskState; + timeRangeLimits?: TimeRangeLimitsState; explore: { form_data: QueryFormData; datasource?: { type: string }; @@ -441,15 +448,19 @@ const v1ChartDataRequest = async ( asyncMode: boolean, queryForceNonces?: string[], ): Promise => { - const payload = await buildV1ChartDataPayload({ - formData: formData as QueryFormData, - resultType, - resultFormat, - force, - queryForceNonces, - setDataMask, - ownState, - }); + const payload = + (!queryForceNonces && resultFormat === 'json' && resultType === 'full' + ? takePrebuiltQueryContext(formData, ownState, force) + : undefined) ?? + (await buildV1ChartDataPayload({ + formData: formData as QueryFormData, + resultType, + resultFormat, + force, + queryForceNonces, + setDataMask, + ownState, + })); // The dashboard id is added to query params for tracking purposes const { slice_id: sliceId } = formData; @@ -1089,6 +1100,26 @@ export function refreshChart( } const timeout = getState().dashboardInfo.common.conf.SUPERSET_WEBSERVER_TIMEOUT; + const ownState = getState().dataMask[chart.id]?.ownState; + const holdMode = chartHoldMode(getState().timeRangeLimits); + const hold = getState().timeRangeLimits?.charts[chart.id]; + // A chart whose check couldn't get its ranges is checked again with the + // same form data; on `valid` it runs as on load. + if ( + holdMode === 'on' && + chart.triggerQuery && + hold?.status === 'unavailable' + ) { + return ( + dispatch( + checkChartRanges( + chart.id, + JSON.parse(hold.signature), + ownState, + ) as unknown as ChartThunkAction>, + ) as Promise + ).then(() => undefined); + } if ( !chart.latestQueryFormData || @@ -1096,16 +1127,34 @@ export function refreshChart( ) { return Promise.resolve(); } - return dispatch( - postChartFormData( - chart.latestQueryFormData, - force, - timeout, - chart.id, - dashboardId, - getState().dataMask[chart.id]?.ownState, - ), - ) as unknown as Promise; + const { latestQueryFormData } = chart; + const post = () => + dispatch( + postChartFormData( + latestQueryFormData, + force, + timeout, + chart.id, + dashboardId, + ownState, + ), + ) as unknown as Promise; + if (holdMode === 'off') { + return post(); + } + // A held chart (or one still waiting for its own check) is never posted. + if (holdMode !== 'on' || chart.triggerQuery) { + return Promise.resolve(); + } + return ( + dispatch( + checkChartRanges( + chart.id, + latestQueryFormData, + ownState, + ) as unknown as ChartThunkAction>, + ) as Promise + ).then(released => (released ? post() : undefined)); }; } diff --git a/superset-frontend/src/components/Chart/chartActions.test.ts b/superset-frontend/src/components/Chart/chartActions.test.ts index 5831675366b9..9e6b40728a79 100644 --- a/superset-frontend/src/components/Chart/chartActions.test.ts +++ b/superset-frontend/src/components/Chart/chartActions.test.ts @@ -587,6 +587,51 @@ describe('chart actions', () => { ); }); + test('shows the time range limit error of the re-sent request after a task fails on the limits', async () => { + const limitError = { + error_type: 'TIME_RANGE_LIMIT_ERROR', + message: "Range can't be longer than 7 days. This one is 30 days.", + level: 'error', + extra: { reason_code: 'MAX_SPAN_EXCEEDED', rule: 'max_span' }, + }; + fetchMock.removeRoute(MOCK_URL); + let calls = 0; + fetchMock.post( + `glob:*${MOCK_URL}*`, + () => { + calls += 1; + return calls === 1 + ? { status: 202, body: { task_ids: ['task-1'] } } + : { status: 400, body: { errors: [limitError] } }; + }, + { name: MOCK_URL }, + ); + waitForAsyncDataStub.mockImplementation( + (_job: unknown, refetch: () => Promise) => refetch(), + ); + + const actionThunk = actions.postChartFormData( + { viz_type: 'my_viz' } as QueryFormData, + false, + undefined, + undefined, + ); + await actionThunk( + dispatch as unknown as actions.ChartThunkDispatch, + mockGetState as unknown as () => actions.RootState, + undefined, + ); + + expect(calls).toBe(2); + const updateFailedAction = dispatch.mock.calls.find( + ([action]) => action?.type === actions.CHART_UPDATE_FAILED, + )?.[0]; + expect(updateFailedAction).toBeDefined(); + expect(updateFailedAction.queriesResponse[0].errors).toEqual([ + limitError, + ]); + }); + test('rejects rather than treating a repeat 202 body as chart data', async () => { // Regression: the post-completion re-issue is always synchronous, so a // 202 body ({task_ids}) can never reach a consumer as if it were rows. diff --git a/superset-frontend/src/components/Datasource/DatasourceModal/DatasourceModal.timeRangeLimits.test.tsx b/superset-frontend/src/components/Datasource/DatasourceModal/DatasourceModal.timeRangeLimits.test.tsx new file mode 100644 index 000000000000..006b380ec5c9 --- /dev/null +++ b/superset-frontend/src/components/Datasource/DatasourceModal/DatasourceModal.timeRangeLimits.test.tsx @@ -0,0 +1,1197 @@ +/** + * 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, + waitFor, + fireEvent, + cleanup, + userEvent, + within, + act, + defaultStore as store, +} from 'spec/helpers/testing-library'; +import fetchMock, { type RouteResponse } from 'fetch-mock'; +import { + FeatureFlag, + isFeatureEnabled, + SupersetClient, +} from '@superset-ui/core'; +import { Constants } from '@superset-ui/core/components'; +import mockDatasource from 'spec/fixtures/mockDatasource'; +import React from 'react'; +import DatasourceModalComponent from '.'; + +jest.mock('@superset-ui/core', () => ({ + ...jest.requireActual('@superset-ui/core'), + isFeatureEnabled: jest.fn(), +})); + +// The "Extra" and "Description" code editors as plain text areas. +jest.mock('@superset-ui/core/components/AsyncAceEditor', () => ({ + ...jest.requireActual('@superset-ui/core/components/AsyncAceEditor'), + TextAreaEditor: ({ + mode, + defaultValue, + onChange, + }: { + mode: string; + defaultValue?: string; + onChange: (value: string) => void; + }) => ( +