diff --git a/packages/@n8n/api-types/src/dto/owner/__tests__/dismiss-banner-request.dto.test.ts b/packages/@n8n/api-types/src/dto/owner/__tests__/dismiss-banner-request.dto.test.ts index ce975c4857..337df042e8 100644 --- a/packages/@n8n/api-types/src/dto/owner/__tests__/dismiss-banner-request.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/owner/__tests__/dismiss-banner-request.dto.test.ts @@ -56,6 +56,8 @@ describe('DismissBannerRequestDto', () => { 'TRIAL', 'NON_PRODUCTION_LICENSE', 'EMAIL_CONFIRMATION', + 'DATA_STORE_STORAGE_LIMIT_WARNING', + 'DATA_STORE_STORAGE_LIMIT_ERROR', ]; expect(bannerNameSchema.options).toEqual(expectedBanners); diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 08d3b13e6e..38b731678e 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -83,7 +83,6 @@ export interface FrontendSettings { }; dataTables: { maxSize: number; - warningThreshold: number; }; personalizationSurveyEnabled: boolean; defaultLocale: string; diff --git a/packages/@n8n/api-types/src/schemas/banner-name.schema.ts b/packages/@n8n/api-types/src/schemas/banner-name.schema.ts index 445bc31d1a..b343cb8dc7 100644 --- a/packages/@n8n/api-types/src/schemas/banner-name.schema.ts +++ b/packages/@n8n/api-types/src/schemas/banner-name.schema.ts @@ -6,6 +6,8 @@ export const bannerNameSchema = z.enum([ 'TRIAL', 'NON_PRODUCTION_LICENSE', 'EMAIL_CONFIRMATION', + 'DATA_STORE_STORAGE_LIMIT_WARNING', + 'DATA_STORE_STORAGE_LIMIT_ERROR', ]); export type BannerName = z.infer; diff --git a/packages/cli/src/modules/data-table/data-store-size-validator.service.ts b/packages/cli/src/modules/data-table/data-store-size-validator.service.ts index 149b8fd01b..4a709846ae 100644 --- a/packages/cli/src/modules/data-table/data-store-size-validator.service.ts +++ b/packages/cli/src/modules/data-table/data-store-size-validator.service.ts @@ -23,10 +23,7 @@ export class DataStoreSizeValidator { return sizeInBytes === undefined; } - private async getCachedSize( - fetchSizeFn: () => Promise, - now = new Date(), - ): Promise { + async getCachedSize(fetchSizeFn: () => Promise, now = new Date()): Promise { // If there's a pending check, wait for it to complete if (this.pendingCheck) { diff --git a/packages/cli/src/modules/data-table/data-store.service.ts b/packages/cli/src/modules/data-table/data-store.service.ts index bfdaab71b2..61f207cfdc 100644 --- a/packages/cli/src/modules/data-table/data-store.service.ts +++ b/packages/cli/src/modules/data-table/data-store.service.ts @@ -425,7 +425,9 @@ export class DataStoreService { } async getDataTablesSize() { - const sizeBytes = await this.dataStoreRepository.findDataTablesSize(); + const sizeBytes = await this.dataStoreSizeValidator.getCachedSize( + async () => await this.dataStoreRepository.findDataTablesSize(), + ); return { sizeBytes, sizeState: this.dataStoreSizeValidator.sizeToState(sizeBytes), diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 05273c7c0f..893ba80f00 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -185,7 +185,6 @@ export class FrontendService { }, dataTables: { maxSize: this.globalConfig.dataTable.maxSize, - warningThreshold: this.globalConfig.dataTable.warningThreshold, }, publicApi: { enabled: isApiEnabled(), diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index ed64d01974..e3cff390fe 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2901,6 +2901,8 @@ "dataStore.deleteRows.title": "Delete Rows", "dataStore.deleteRows.confirmation": "Are you sure you want to delete {count} row? | Are you sure you want to delete {count} rows?", "dataStore.deleteRows.error": "Error deleting rows", + "dataStore.banner.storageLimitWarning.message": "{usage} of Data tables storage used. Delete data to avoid errors", + "dataStore.banner.storageLimitError.message": "{usage} of Data tables storage used, operations may fail. Delete data to avoid errors", "dataStore.error.tableNotInitialized": "Table not initialized", "dataStore.noRows": "No rows", "settings.ldap": "LDAP", diff --git a/packages/frontend/editor-ui/src/__tests__/defaults.ts b/packages/frontend/editor-ui/src/__tests__/defaults.ts index 5a28c3d2a2..e4f8e4d7b9 100644 --- a/packages/frontend/editor-ui/src/__tests__/defaults.ts +++ b/packages/frontend/editor-ui/src/__tests__/defaults.ts @@ -112,7 +112,6 @@ export const defaultSettings: FrontendSettings = { }, dataTables: { maxSize: 0, - warningThreshold: 0, }, workflowCallerPolicyDefaultOption: 'any', workflowTagsDisabled: false, diff --git a/packages/frontend/editor-ui/src/components/NodeSettings.vue b/packages/frontend/editor-ui/src/components/NodeSettings.vue index 99d630d271..b7b01dc0c4 100644 --- a/packages/frontend/editor-ui/src/components/NodeSettings.vue +++ b/packages/frontend/editor-ui/src/components/NodeSettings.vue @@ -50,6 +50,7 @@ import { useTelemetry } from '@/composables/useTelemetry'; import { importCurlEventBus, ndvEventBus } from '@/event-bus'; import { ProjectTypes } from '@/types/projects.types'; import FreeAiCreditsCallout from '@/components/FreeAiCreditsCallout.vue'; +import NodeStorageLimitCallout from '@/features/dataStore/components/NodeStorageLimitCallout.vue'; import { useResizeObserver } from '@vueuse/core'; import { useNodeSettingsParameters } from '@/composables/useNodeSettingsParameters'; import { N8nBlockUi, N8nIcon, N8nNotice, N8nText } from '@n8n/design-system'; @@ -681,6 +682,7 @@ function handleSelectAction(params: INodeParameters) { " /> + { projectsStore.teamProjectsLimit = -1; settingsStore.settings.folders = { enabled: false }; - settingsStore.isDataStoreFeatureEnabled = true; + settingsStore.isDataTableFeatureEnabled = true; // Setup default moduleTabs structure uiStore.moduleTabs = { @@ -123,7 +123,7 @@ describe('ProjectHeader', () => { }); it('Overview: should render the correct title and subtitle', async () => { - settingsStore.isDataStoreFeatureEnabled = false; + settingsStore.isDataTableFeatureEnabled = false; vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(true); const { getByTestId, rerender } = renderComponent(); const overviewSubtitle = 'All the workflows, credentials and executions you have access to'; @@ -147,7 +147,7 @@ describe('ProjectHeader', () => { }); it('Personal: should render the correct title and subtitle', async () => { - settingsStore.isDataStoreFeatureEnabled = false; + settingsStore.isDataTableFeatureEnabled = false; vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false); vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false); const { getByTestId, rerender } = renderComponent(); @@ -468,7 +468,7 @@ describe('ProjectHeader', () => { }); it('should not render datastore menu item if data store feature is disabled', () => { - settingsStore.isDataStoreFeatureEnabled = false; + settingsStore.isDataTableFeatureEnabled = false; const { getByTestId } = renderComponent(); const actionsContainer = getByTestId('add-resource-actions'); expect(actionsContainer).toBeInTheDocument(); diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue index f32c94d499..d11bd4bcc9 100644 --- a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue @@ -142,7 +142,7 @@ const menu = computed(() => { }); } - if (settingsStore.isDataStoreFeatureEnabled) { + if (settingsStore.isDataTableFeatureEnabled) { // TODO: this should probably be moved to the module descriptor as a setting items.push({ value: ACTION_TYPES.DATA_STORE, @@ -242,13 +242,13 @@ const sectionDescription = computed(() => { return i18n.baseText('projects.header.shared.subtitle'); } else if (projectPages.isOverviewSubPage) { return i18n.baseText( - settingsStore.isDataStoreFeatureEnabled + settingsStore.isDataTableFeatureEnabled ? 'projects.header.overview.subtitleWithDataTables' : 'projects.header.overview.subtitle', ); } else if (isPersonalProject.value) { return i18n.baseText( - settingsStore.isDataStoreFeatureEnabled + settingsStore.isDataTableFeatureEnabled ? 'projects.header.personal.subtitleWithDataTables' : 'projects.header.personal.subtitle', ); diff --git a/packages/frontend/editor-ui/src/components/banners/BannerStack.vue b/packages/frontend/editor-ui/src/components/banners/BannerStack.vue index f78aa0d851..1b5b6b858b 100644 --- a/packages/frontend/editor-ui/src/components/banners/BannerStack.vue +++ b/packages/frontend/editor-ui/src/components/banners/BannerStack.vue @@ -4,6 +4,8 @@ import TrialOverBanner from '@/components/banners/TrialOverBanner.vue'; import TrialBanner from '@/components/banners/TrialBanner.vue'; import V1Banner from '@/components/banners/V1Banner.vue'; import EmailConfirmationBanner from '@/components/banners/EmailConfirmationBanner.vue'; +import DataStoreStorageLimitWarningBanner from '@/components/banners/DataStoreStorageLimitWarningBanner.vue'; +import DataStoreStorageLimitErrorBanner from '@/components/banners/DataStoreStorageLimitErrorBanner.vue'; import type { Component } from 'vue'; import type { N8nBanners } from '@/Interface'; @@ -17,6 +19,14 @@ export const N8N_BANNERS: N8nBanners = { EMAIL_CONFIRMATION: { priority: 250, component: EmailConfirmationBanner as Component }, TRIAL: { priority: 150, component: TrialBanner as Component }, NON_PRODUCTION_LICENSE: { priority: 140, component: NonProductionLicenseBanner as Component }, + DATA_STORE_STORAGE_LIMIT_WARNING: { + priority: 300, + component: DataStoreStorageLimitWarningBanner as Component, + }, + DATA_STORE_STORAGE_LIMIT_ERROR: { + priority: 400, + component: DataStoreStorageLimitErrorBanner as Component, + }, }; diff --git a/packages/frontend/editor-ui/src/components/banners/DataStoreStorageLimitErrorBanner.vue b/packages/frontend/editor-ui/src/components/banners/DataStoreStorageLimitErrorBanner.vue new file mode 100644 index 0000000000..95253e04fc --- /dev/null +++ b/packages/frontend/editor-ui/src/components/banners/DataStoreStorageLimitErrorBanner.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/frontend/editor-ui/src/components/banners/DataStoreStorageLimitWarningBanner.vue b/packages/frontend/editor-ui/src/components/banners/DataStoreStorageLimitWarningBanner.vue new file mode 100644 index 0000000000..a45fe44266 --- /dev/null +++ b/packages/frontend/editor-ui/src/components/banners/DataStoreStorageLimitWarningBanner.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts index f03322f5e3..2941c2d0c6 100644 --- a/packages/frontend/editor-ui/src/constants.ts +++ b/packages/frontend/editor-ui/src/constants.ts @@ -235,6 +235,8 @@ export const CREDENTIAL_ONLY_HTTP_NODE_VERSION = 4.1; // template categories export const TEMPLATE_CATEGORY_AI = 'categories/ai'; +export const DATA_STORE_NODES = [DATA_STORE_NODE_TYPE, DATA_STORE_TOOL_NODE_TYPE]; + export const EXECUTABLE_TRIGGER_NODE_TYPES = [ START_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE, @@ -256,8 +258,7 @@ export const NODES_USING_CODE_NODE_EDITOR = [ AI_TRANSFORM_NODE_TYPE, ]; export const MODULE_ENABLED_NODES = [ - { nodeType: DATA_STORE_NODE_TYPE, module: DATA_STORE_MODULE_NAME }, - { nodeType: DATA_STORE_TOOL_NODE_TYPE, module: DATA_STORE_MODULE_NAME }, + ...DATA_STORE_NODES.map((nodeType) => ({ nodeType, module: DATA_STORE_MODULE_NAME })), ]; export const NODE_POSITION_CONFLICT_ALLOWLIST = [STICKY_NODE_TYPE]; diff --git a/packages/frontend/editor-ui/src/features/dataStore/components/NodeStorageLimitCallout.vue b/packages/frontend/editor-ui/src/features/dataStore/components/NodeStorageLimitCallout.vue new file mode 100644 index 0000000000..48f128ad90 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataStore/components/NodeStorageLimitCallout.vue @@ -0,0 +1,50 @@ + + diff --git a/packages/frontend/editor-ui/src/features/dataStore/dataStore.api.ts b/packages/frontend/editor-ui/src/features/dataStore/dataStore.api.ts index 056274c05a..ea411b3767 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/dataStore.api.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/dataStore.api.ts @@ -7,6 +7,7 @@ import type { DataStoreColumn, DataStoreRow, } from '@/features/dataStore/datastore.types'; +import type { DataTablesSizeResult } from 'n8n-workflow'; export const fetchDataStoresApi = async ( context: IRestApiContext, @@ -203,3 +204,11 @@ export const deleteDataStoreRowsApi = async ( }, ); }; + +export const fetchDataStoreGlobalLimitInBytes = async (context: IRestApiContext) => { + return await makeRestApiRequest( + context, + 'GET', + '/data-tables-global/limits', + ); +}; diff --git a/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts b/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts index 064ed487c1..2b8e8ca580 100644 --- a/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts +++ b/packages/frontend/editor-ui/src/features/dataStore/dataStore.store.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia'; import { DATA_STORE_STORE } from '@/features/dataStore/constants'; -import { ref } from 'vue'; +import { computed, ref } from 'vue'; import { useRootStore } from '@n8n/stores/useRootStore'; import { fetchDataStoresApi, @@ -14,6 +14,7 @@ import { insertDataStoreRowApi, updateDataStoreRowsApi, deleteDataStoreRowsApi, + fetchDataStoreGlobalLimitInBytes, } from '@/features/dataStore/dataStore.api'; import type { DataStore, @@ -22,13 +23,22 @@ import type { } from '@/features/dataStore/datastore.types'; import { useProjectsStore } from '@/stores/projects.store'; import { reorderItem } from '@/features/dataStore/utils'; +import { type DataTableSizeStatus } from 'n8n-workflow'; +import { useSettingsStore } from '@/stores/settings.store'; export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => { const rootStore = useRootStore(); const projectStore = useProjectsStore(); + const settingsStore = useSettingsStore(); const dataStores = ref([]); const totalCount = ref(0); + const dataStoreSize = ref(0); + const dataStoreSizeLimitState = ref('ok'); + + const maxSizeMB = computed(() => + Math.floor(settingsStore.settings?.dataTables?.maxSize / 1024 / 1024), + ); const fetchDataStores = async (projectId: string, page: number, pageSize: number) => { const response = await fetchDataStoresApi(rootStore.restApiContext, projectId, { @@ -207,10 +217,21 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => { return await deleteDataStoreRowsApi(rootStore.restApiContext, dataStoreId, rowIds, projectId); }; + const fetchDataStoreSize = async () => { + const result = await fetchDataStoreGlobalLimitInBytes(rootStore.restApiContext); + dataStoreSize.value = Number((result.sizeBytes / 1024 / 1024).toFixed(2)); + dataStoreSizeLimitState.value = result.sizeState; + return result; + }; + return { dataStores, totalCount, fetchDataStores, + fetchDataStoreSize, + dataStoreSize: computed(() => dataStoreSize.value), + dataStoreSizeLimitState: computed(() => dataStoreSizeLimitState.value), + maxSizeMB, createDataStore, deleteDataStore, updateDataStore, diff --git a/packages/frontend/editor-ui/src/init.ts b/packages/frontend/editor-ui/src/init.ts index 1813c1a7f3..4e591a2b23 100644 --- a/packages/frontend/editor-ui/src/init.ts +++ b/packages/frontend/editor-ui/src/init.ts @@ -27,6 +27,7 @@ import { useI18n } from '@n8n/i18n'; import { useRootStore } from '@n8n/stores/useRootStore'; import { h } from 'vue'; import { useRolesStore } from './stores/roles.store'; +import { useDataStoreStore } from '@/features/dataStore/dataStore.store'; export const state = { initialized: false, @@ -134,6 +135,7 @@ export async function initializeAuthenticatedFeatures( const insightsStore = useInsightsStore(); const uiStore = useUIStore(); const versionsStore = useVersionsStore(); + const dataStoreStore = useDataStoreStore(); if (sourceControlStore.isEnterpriseSourceControlEnabled) { try { @@ -172,6 +174,15 @@ export async function initializeAuthenticatedFeatures( }); } + if (settingsStore.isDataTableFeatureEnabled) { + const { sizeState } = await dataStoreStore.fetchDataStoreSize(); + if (sizeState === 'error') { + uiStore.pushBannerToStack('DATA_STORE_STORAGE_LIMIT_ERROR'); + } else if (sizeState === 'warn') { + uiStore.pushBannerToStack('DATA_STORE_STORAGE_LIMIT_WARNING'); + } + } + if (insightsStore.isSummaryEnabled) { void insightsStore.weeklySummary.execute(); } diff --git a/packages/frontend/editor-ui/src/stores/settings.store.ts b/packages/frontend/editor-ui/src/stores/settings.store.ts index 7670abbb35..b2ccaaaa04 100644 --- a/packages/frontend/editor-ui/src/stores/settings.store.ts +++ b/packages/frontend/editor-ui/src/stores/settings.store.ts @@ -143,7 +143,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { const isFoldersFeatureEnabled = computed(() => folders.value.enabled); - const isDataStoreFeatureEnabled = computed(() => isModuleActive('data-table')); + const isDataTableFeatureEnabled = computed(() => isModuleActive('data-table')); const areTagsEnabled = computed(() => settings.value.workflowTagsDisabled !== undefined ? !settings.value.workflowTagsDisabled : true, @@ -186,6 +186,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { const setSettings = (newSettings: FrontendSettings) => { settings.value = newSettings; + userManagement.value = newSettings.userManagement; if (userManagement.value) { userManagement.value.showSetupOnFirstLoad = @@ -386,6 +387,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { isMFAEnforced, activeModules, isModuleActive, - isDataStoreFeatureEnabled, + isDataTableFeatureEnabled, }; });