feat(editor): Show data table storage limit banners (no-changelog) (#19175)

Co-authored-by: Ricardo Espinoza <ricardo@n8n.io>
Co-authored-by: Charlie Kolb <charlie@n8n.io>
This commit is contained in:
Svetoslav Dekov
2025-09-12 09:51:32 +03:00
committed by GitHub
parent c3ce2f4819
commit c4d26982e3
20 changed files with 171 additions and 20 deletions

View File

@@ -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);

View File

@@ -83,7 +83,6 @@ export interface FrontendSettings {
};
dataTables: {
maxSize: number;
warningThreshold: number;
};
personalizationSurveyEnabled: boolean;
defaultLocale: string;

View File

@@ -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<typeof bannerNameSchema>;

View File

@@ -23,10 +23,7 @@ export class DataStoreSizeValidator {
return sizeInBytes === undefined;
}
private async getCachedSize(
fetchSizeFn: () => Promise<number>,
now = new Date(),
): Promise<number> {
async getCachedSize(fetchSizeFn: () => Promise<number>, now = new Date()): Promise<number> {
// If there's a pending check, wait for it to complete
if (this.pendingCheck) {

View File

@@ -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),

View File

@@ -185,7 +185,6 @@ export class FrontendService {
},
dataTables: {
maxSize: this.globalConfig.dataTable.maxSize,
warningThreshold: this.globalConfig.dataTable.warningThreshold,
},
publicApi: {
enabled: isApiEnabled(),

View File

@@ -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",

View File

@@ -112,7 +112,6 @@ export const defaultSettings: FrontendSettings = {
},
dataTables: {
maxSize: 0,
warningThreshold: 0,
},
workflowCallerPolicyDefaultOption: 'any',
workflowTagsDisabled: false,

View File

@@ -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) {
"
/>
<FreeAiCreditsCallout />
<NodeStorageLimitCallout />
<NodeActionsList
v-if="openPanel === 'action'"
class="action-tab"

View File

@@ -86,7 +86,7 @@ describe('ProjectHeader', () => {
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();

View File

@@ -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',
);

View File

@@ -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,
},
};
</script>

View File

@@ -0,0 +1,22 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import BaseBanner from '@/components/banners/BaseBanner.vue';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
const dataStoreStore = useDataStoreStore();
const i18n = useI18n();
</script>
<template>
<BaseBanner name="DATA_STORE_STORAGE_LIMIT_ERROR" :dismissible="true" theme="danger">
<template #mainContent>
<span>{{
i18n.baseText('dataStore.banner.storageLimitError.message', {
interpolate: {
usage: `${dataStoreStore.dataStoreSize} / ${dataStoreStore.maxSizeMB}MB`,
},
})
}}</span>
</template>
</BaseBanner>
</template>

View File

@@ -0,0 +1,22 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import BaseBanner from '@/components/banners/BaseBanner.vue';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
const dataStoreStore = useDataStoreStore();
const i18n = useI18n();
</script>
<template>
<BaseBanner name="DATA_STORE_STORAGE_LIMIT_WARNING" :dismissible="true" theme="warning">
<template #mainContent>
<span>{{
i18n.baseText('dataStore.banner.storageLimitWarning.message', {
interpolate: {
usage: `${dataStoreStore.dataStoreSize} / ${dataStoreStore.maxSizeMB}MB`,
},
})
}}</span>
</template>
</BaseBanner>
</template>

View File

@@ -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];

View File

@@ -0,0 +1,50 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useI18n } from '@n8n/i18n';
import { N8nCallout } from '@n8n/design-system';
import { useNDVStore } from '@/stores/ndv.store';
import { DATA_STORE_NODES } from '@/constants';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
const i18n = useI18n();
const nvdStore = useNDVStore();
const dataStoreStore = useDataStoreStore();
const calloutType = computed(() => {
if (!DATA_STORE_NODES.includes(nvdStore.activeNode?.type ?? '')) {
return null;
}
const sizeLimitState = dataStoreStore.dataStoreSizeLimitState;
switch (sizeLimitState) {
case 'error':
return 'danger';
case 'warn':
return 'warning';
default:
return null;
}
});
</script>
<template>
<N8nCallout v-if="calloutType" :theme="calloutType" class="mt-xs">
<span v-if="calloutType === 'danger'">
{{
i18n.baseText('dataStore.banner.storageLimitError.message', {
interpolate: {
usage: `${dataStoreStore.dataStoreSize} / ${dataStoreStore.maxSizeMB}MB`,
},
})
}}
</span>
<span v-else>
{{
i18n.baseText('dataStore.banner.storageLimitWarning.message', {
interpolate: {
usage: `${dataStoreStore.dataStoreSize} / ${dataStoreStore.maxSizeMB}MB`,
},
})
}}
</span>
</N8nCallout>
</template>

View File

@@ -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<DataTablesSizeResult>(
context,
'GET',
'/data-tables-global/limits',
);
};

View File

@@ -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<DataStore[]>([]);
const totalCount = ref(0);
const dataStoreSize = ref(0);
const dataStoreSizeLimitState = ref<DataTableSizeStatus>('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,

View File

@@ -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();
}

View File

@@ -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,
};
});