diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/InvalidateNowButton.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/InvalidateNowButton.tsx index ce6eca58669baf482b1714a4ed75cbd434add749..3e5c0955e9407ac4556a140ac967c666a8ba09ce 100644 --- a/enterprise/frontend/src/metabase-enterprise/caching/components/InvalidateNowButton.tsx +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/InvalidateNowButton.tsx @@ -32,7 +32,7 @@ export const InvalidateNowButton = ({ { include: "overrides", [targetModel]: targetId }, { hasBody: false }, ); - await resolveSmoothly(invalidate); + await resolveSmoothly([invalidate]); } catch (e) { if (isErrorWithMessage(e)) { dispatch( diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/ModelPersistenceConfiguration.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/ModelPersistenceConfiguration.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aff4e64276b3b26753fa4f88aab88d1bcefebb0f --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/ModelPersistenceConfiguration.tsx @@ -0,0 +1,178 @@ +import type { ChangeEventHandler } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { c, t } from "ttag"; + +import { ModelCachingScheduleWidget } from "metabase/admin/settings/components/widgets/ModelCachingScheduleWidget/ModelCachingScheduleWidget"; +import { useSetting } from "metabase/common/hooks"; +import ExternalLink from "metabase/core/components/ExternalLink"; +import { useDispatch, useSelector } from "metabase/lib/redux"; +import MetabaseSettings from "metabase/lib/settings"; +import { refreshSiteSettings } from "metabase/redux/settings"; +import { addUndo, dismissUndo } from "metabase/redux/undo"; +import { + getApplicationName, + getShowMetabaseLinks, +} from "metabase/selectors/whitelabel"; +import { PersistedModelsApi } from "metabase/services"; +import { Stack, Switch, Text } from "metabase/ui"; + +export const ModelPersistenceConfiguration = () => { + const showMetabaseLinks = useSelector(getShowMetabaseLinks); + const persistenceEnabledInAPI = useSetting("persisted-models-enabled"); + + const [persistenceEnabled, setPersistenceEnabled] = useState(false); + useEffect(() => { + setPersistenceEnabled(persistenceEnabledInAPI); + }, [persistenceEnabledInAPI]); + + const modelCachingSchedule = useSetting( + "persisted-model-refresh-cron-schedule", + ); + + const modelCachingSetting = { + value: modelCachingSchedule, + options: [ + { + value: "0 0 0/1 * * ? *", + name: t`Hour`, + }, + { + value: "0 0 0/2 * * ? *", + name: t`2 hours`, + }, + { + value: "0 0 0/3 * * ? *", + name: t`3 hours`, + }, + { + value: "0 0 0/6 * * ? *", + name: t`6 hours`, + }, + { + value: "0 0 0/12 * * ? *", + name: t`12 hours`, + }, + { + value: "0 0 0 ? * * *", + name: t`24 hours`, + }, + { + value: "custom", + name: t`Custom…`, + }, + ], + }; + const dispatch = useDispatch(); + + const showLoadingToast = useCallback(async () => { + const result = await dispatch( + addUndo({ + icon: "info", + message: t`Loading...`, + }), + ); + return result?.payload?.id as number; + }, [dispatch]); + + const dismissLoadingToast = useCallback( + (toastId: number) => { + dispatch(dismissUndo(toastId)); + }, + [dispatch], + ); + + const showErrorToast = useCallback(() => { + dispatch( + addUndo({ + icon: "warning", + toastColor: "error", + message: t`An error occurred`, + }), + ); + }, [dispatch]); + + const showSuccessToast = useCallback(() => { + dispatch(addUndo({ message: "Saved" })); + }, [dispatch]); + + const resolveWithToasts = useCallback( + async (promises: Promise<any>[]) => { + let loadingToastId; + try { + loadingToastId = await showLoadingToast(); + await Promise.all(promises); + showSuccessToast(); + } catch (e) { + showErrorToast(); + } finally { + if (loadingToastId !== undefined) { + dismissLoadingToast(loadingToastId); + } + } + }, + [showLoadingToast, showSuccessToast, showErrorToast, dismissLoadingToast], + ); + + const applicationName = useSelector(getApplicationName); + + const onSwitchChanged = useCallback<ChangeEventHandler<HTMLInputElement>>( + async e => { + const shouldEnable = e.target.checked; + setPersistenceEnabled(shouldEnable); + const promise = shouldEnable + ? PersistedModelsApi.enablePersistence() + : PersistedModelsApi.disablePersistence(); + await resolveWithToasts([promise]); + }, + [resolveWithToasts, setPersistenceEnabled], + ); + + return ( + <Stack spacing="xl" maw="40rem"> + <div> + <p> + {t`Enable model persistence to make your models (and the queries that use them) load faster.`} + </p> + <p> + {c( + // eslint-disable-next-line no-literal-metabase-strings -- This string provides context for translators + '{0} is either "Metabase" or the customized name of the application.', + ) + .t`This will create a table for each of your models in a dedicated schema. ${applicationName} will refresh them on a schedule. Questions and queries that use your models will query these tables.`} + {showMetabaseLinks && ( + <> + {" "} + <ExternalLink + key="model-caching-link" + href={MetabaseSettings.docsUrl("data-modeling/models")} + >{t`Learn more`}</ExternalLink> + </> + )} + </p> + <Switch + mt="sm" + label={ + <Text fw="bold"> + {persistenceEnabled ? t`Enabled` : t`Disabled`} + </Text> + } + onChange={onSwitchChanged} + checked={persistenceEnabled} + /> + </div> + {persistenceEnabled && ( + <div> + <ModelCachingScheduleWidget + setting={modelCachingSetting} + onChange={async value => { + await resolveWithToasts([ + PersistedModelsApi.setRefreshSchedule({ cron: value }), + dispatch(refreshSiteSettings({})), + ]); + }} + /> + </div> + )} + </Stack> + ); +}; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/ModelPersistenceTab.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/ModelPersistenceTab.tsx new file mode 100644 index 0000000000000000000000000000000000000000..43995ff54f7653542888c65a8c4a40d70d14a04c --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/ModelPersistenceTab.tsx @@ -0,0 +1,12 @@ +import { t } from "ttag"; + +import { TabId } from "metabase/admin/performance/components/PerformanceApp"; +import { Tab } from "metabase/admin/performance/components/PerformanceApp.styled"; + +export const ModelPersistenceTab = () => { + return ( + <Tab key="ModelPersistence" value={TabId.ModelPersistence}> + {t`Model persistence`} + </Tab> + ); +}; diff --git a/enterprise/frontend/src/metabase-enterprise/caching/index.tsx b/enterprise/frontend/src/metabase-enterprise/caching/index.tsx index 4d1e2fa30cc833adb81dfcf5423e135bf0d174b8..90fba28b7abf1b816d7da7fdeed9e5779784e407 100644 --- a/enterprise/frontend/src/metabase-enterprise/caching/index.tsx +++ b/enterprise/frontend/src/metabase-enterprise/caching/index.tsx @@ -5,6 +5,8 @@ import CacheTTLField from "./components/CacheTTLField"; import { DashboardStrategySidebar } from "./components/DashboardStrategySidebar"; import { GranularControlsExplanation } from "./components/GranularControlsExplanation"; import { InvalidateNowButton } from "./components/InvalidateNowButton"; +import { ModelPersistenceConfiguration } from "./components/ModelPersistenceConfiguration"; +import { ModelPersistenceTab } from "./components/ModelPersistenceTab"; import QuestionCacheTTLField from "./components/QuestionCacheTTLField"; import { SidebarCacheForm } from "./components/SidebarCacheForm"; import { SidebarCacheSection } from "./components/SidebarCacheSection"; @@ -44,4 +46,6 @@ if (hasPremiumFeature("cache_granular_controls")) { ttl: PLUGIN_CACHING.strategies.ttl, nocache: PLUGIN_CACHING.strategies.nocache, }; + PLUGIN_CACHING.ModelPersistenceTab = ModelPersistenceTab; + PLUGIN_CACHING.ModelPersistenceConfiguration = ModelPersistenceConfiguration; } diff --git a/frontend/src/metabase-types/api/mocks/settings.ts b/frontend/src/metabase-types/api/mocks/settings.ts index 198b72434add6123ad711b1087edd6b402587866..a145d3bb31d8c1cfc54c99fd5bbb058b46e2c7fd 100644 --- a/frontend/src/metabase-types/api/mocks/settings.ts +++ b/frontend/src/metabase-types/api/mocks/settings.ts @@ -199,6 +199,7 @@ export const createMockSettings = ( "other-sso-enabled?": null, "password-complexity": { total: 6, digit: 1 }, "persisted-models-enabled": false, + "persisted-model-refresh-cron-schedule": "0 0 0/6 * * ? *", "premium-embedding-token": null, "read-only-mode": false, "report-timezone-short": "UTC", diff --git a/frontend/src/metabase-types/api/settings.ts b/frontend/src/metabase-types/api/settings.ts index d597a4f52ea9e4024a075d17f8ced4a2094d4090..3f02857c885635d4198e07c9984311b7ad23809c 100644 --- a/frontend/src/metabase-types/api/settings.ts +++ b/frontend/src/metabase-types/api/settings.ts @@ -302,6 +302,7 @@ interface PublicSettings { "other-sso-enabled?": boolean | null; // TODO: FIXME! This is an enterprise-only setting! "password-complexity": PasswordComplexity; "persisted-models-enabled": boolean; + "persisted-model-refresh-cron-schedule": string; "report-timezone-long": string; "report-timezone-short": string; "session-cookies": boolean | null; diff --git a/frontend/src/metabase/admin/performance/components/PerformanceApp.tsx b/frontend/src/metabase/admin/performance/components/PerformanceApp.tsx index 1b6c43ccb5de0c5247b22fa640d5147938a9dca0..ff73a08727b8298a01326aa157ffefa09e9e9af5 100644 --- a/frontend/src/metabase/admin/performance/components/PerformanceApp.tsx +++ b/frontend/src/metabase/admin/performance/components/PerformanceApp.tsx @@ -2,6 +2,7 @@ import { useLayoutEffect, useRef, useState } from "react"; import type { Route } from "react-router"; import { t } from "ttag"; +import { PLUGIN_CACHING } from "metabase/plugins"; import type { TabsValue } from "metabase/ui"; import { Flex, Tabs } from "metabase/ui"; @@ -10,6 +11,7 @@ import { StrategyEditorForDatabases } from "./StrategyEditorForDatabases"; export enum TabId { DataCachingSettings = "dataCachingSettings", + ModelPersistence = "modelPersistence", } const validTabIds = new Set(Object.values(TabId).map(String)); const isValidTabId = (tab: TabsValue): tab is TabId => @@ -56,11 +58,19 @@ export const PerformanceApp = ({ route }: { route: Route }) => { <Tab key="DataCachingSettings" value={TabId.DataCachingSettings}> {t`Data caching settings`} </Tab> + <PLUGIN_CACHING.ModelPersistenceTab /> </TabsList> <TabsPanel key={tabId} value={tabId} p="1rem 2.5rem"> - <Flex style={{ flex: 1 }} bg="bg-light" h="100%"> - <StrategyEditorForDatabases route={route} /> - </Flex> + {tabId === TabId.DataCachingSettings && ( + <Flex style={{ flex: 1 }} bg="bg-light" h="100%"> + <StrategyEditorForDatabases route={route} /> + </Flex> + )} + {tabId === TabId.ModelPersistence && ( + <Flex style={{ flex: 1 }} bg="bg-light" h="100%"> + <PLUGIN_CACHING.ModelPersistenceConfiguration /> + </Flex> + )} </TabsPanel> </Tabs> ); diff --git a/frontend/src/metabase/admin/performance/utils.tsx b/frontend/src/metabase/admin/performance/utils.tsx index 54286b46bbe22be65b8da9d99c37e7a588a4d00a..c1dc6ac7a183c94d3a6b63fc54d5fcc93ffa37cc 100644 --- a/frontend/src/metabase/admin/performance/utils.tsx +++ b/frontend/src/metabase/admin/performance/utils.tsx @@ -173,10 +173,10 @@ const delay = (milliseconds: number) => * An example of jumpiness: clicking a save button results in * displaying a loading spinner for 10 ms and then a success message */ export const resolveSmoothly = async ( - promise: Promise<any>, + promises: Promise<any>[], timeout: number = 300, ) => { - return await Promise.all([delay(timeout), promise]); + return await Promise.all([delay(timeout), ...promises]); }; export const getFrequencyFromCron = (cron: string) => { diff --git a/frontend/src/metabase/admin/settings/components/widgets/ModelCachingScheduleWidget/ModelCachingScheduleWidget.jsx b/frontend/src/metabase/admin/settings/components/widgets/ModelCachingScheduleWidget/ModelCachingScheduleWidget.jsx index b7d666c4b20bdbfe2cb0d08392aa79cd87a995f1..277156e0c9d39db57fa6c9ba73b834aa7c208b6a 100644 --- a/frontend/src/metabase/admin/settings/components/widgets/ModelCachingScheduleWidget/ModelCachingScheduleWidget.jsx +++ b/frontend/src/metabase/admin/settings/components/widgets/ModelCachingScheduleWidget/ModelCachingScheduleWidget.jsx @@ -32,11 +32,7 @@ function formatCronExpression(cronExpression) { return partsWithoutSecondsAndYear.join(" "); } -const PersistedModelRefreshIntervalWidget = ({ - setting, - disabled, - onChange, -}) => { +export const ModelCachingScheduleWidget = ({ setting, disabled, onChange }) => { const [isCustom, setCustom] = useState(isCustomSchedule(setting)); const [customCronSchedule, setCustomCronSchedule] = useState( // We don't allow to specify the "year" component, but it's present in the value @@ -93,6 +89,4 @@ const PersistedModelRefreshIntervalWidget = ({ ); }; -PersistedModelRefreshIntervalWidget.propTypes = propTypes; - -export default PersistedModelRefreshIntervalWidget; +ModelCachingScheduleWidget.propTypes = propTypes; diff --git a/frontend/src/metabase/admin/settings/components/widgets/ModelCachingScheduleWidget/index.ts b/frontend/src/metabase/admin/settings/components/widgets/ModelCachingScheduleWidget/index.ts deleted file mode 100644 index 23e74e3577d3e25d12490f7ac40e86c5eca73da6..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/admin/settings/components/widgets/ModelCachingScheduleWidget/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./ModelCachingScheduleWidget"; diff --git a/frontend/src/metabase/admin/settings/selectors.js b/frontend/src/metabase/admin/settings/selectors.js index 49a68c836d24b153552b27f002878fe7fff07594..516e78d3f72026115685a2b97e24dc0e78f75641 100644 --- a/frontend/src/metabase/admin/settings/selectors.js +++ b/frontend/src/metabase/admin/settings/selectors.js @@ -1,11 +1,10 @@ import { createSelector } from "@reduxjs/toolkit"; -import { jt, t } from "ttag"; +import { t } from "ttag"; import _ from "underscore"; import { SMTPConnectionForm } from "metabase/admin/settings/components/Email/SMTPConnectionForm"; import Breadcrumbs from "metabase/components/Breadcrumbs"; import { DashboardSelector } from "metabase/components/DashboardSelector"; -import ExternalLink from "metabase/core/components/ExternalLink"; import MetabaseSettings from "metabase/lib/settings"; import { PLUGIN_ADMIN_SETTINGS_UPDATES, @@ -14,7 +13,6 @@ import { } from "metabase/plugins"; import { refreshCurrentUser } from "metabase/redux/user"; import { getUserIsAdmin } from "metabase/selectors/user"; -import { PersistedModelsApi } from "metabase/services"; import { trackCustomHomepageDashboardEnabled, @@ -34,7 +32,6 @@ import { import { EmbeddingSwitchWidget } from "./components/widgets/EmbeddingSwitchWidget"; import FormattingWidget from "./components/widgets/FormattingWidget"; import HttpsOnlyWidget from "./components/widgets/HttpsOnlyWidget"; -import ModelCachingScheduleWidget from "./components/widgets/ModelCachingScheduleWidget"; import { EmbeddedResources, PublicLinksActionListing, @@ -515,71 +512,6 @@ export const ADMIN_SETTINGS_SECTIONS = { component: SettingsLicense, settings: [], }, - caching: { - name: t`Caching`, - order: 120, - settings: [ - { - key: "persisted-models-enabled", - display_name: t`Models`, - description: jt`Enabling caching will create tables for your models in a dedicated schema and Metabase will refresh them on a schedule. Questions based on your models will query these tables. ${( - <ExternalLink - key="model-caching-link" - href={MetabaseSettings.docsUrl("data-modeling/models")} - >{t`Learn more`}</ExternalLink> - )}.`, - type: "boolean", - disableDefaultUpdate: true, - onChanged: async (wasEnabled, isEnabled) => { - if (isEnabled) { - await PersistedModelsApi.enablePersistence(); - } else { - await PersistedModelsApi.disablePersistence(); - } - }, - }, - { - key: "persisted-model-refresh-cron-schedule", - noHeader: true, - type: "select", - options: [ - { - value: "0 0 0/1 * * ? *", - name: t`Hour`, - }, - { - value: "0 0 0/2 * * ? *", - name: t`2 hours`, - }, - { - value: "0 0 0/3 * * ? *", - name: t`3 hours`, - }, - { - value: "0 0 0/6 * * ? *", - name: t`6 hours`, - }, - { - value: "0 0 0/12 * * ? *", - name: t`12 hours`, - }, - { - value: "0 0 0 ? * * *", - name: t`24 hours`, - }, - { - value: "custom", - name: t`Custom…`, - }, - ], - widget: ModelCachingScheduleWidget, - disableDefaultUpdate: true, - getHidden: settings => !settings["persisted-models-enabled"], - onChanged: (previousValue, value) => - PersistedModelsApi.setRefreshSchedule({ cron: value }), - }, - ], - }, metabot: { name: t`Metabot`, order: 130, diff --git a/frontend/src/metabase/plugins/index.ts b/frontend/src/metabase/plugins/index.ts index ffc749423db405a51bf6584aed5e3ba138862089..429e49e3c0292b8478d8c2d7f083b5884718d8d9 100644 --- a/frontend/src/metabase/plugins/index.ts +++ b/frontend/src/metabase/plugins/index.ts @@ -364,6 +364,8 @@ export const PLUGIN_CACHING = { hasQuestionCacheSection: (_question: Question) => false, canOverrideRootStrategy: false, strategies: strategies, + ModelPersistenceTab: PluginPlaceholder as any, + ModelPersistenceConfiguration: PluginPlaceholder as any, }; export const PLUGIN_REDUCERS: {