mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -83,7 +83,6 @@ export interface FrontendSettings {
|
||||
};
|
||||
dataTables: {
|
||||
maxSize: number;
|
||||
warningThreshold: number;
|
||||
};
|
||||
personalizationSurveyEnabled: boolean;
|
||||
defaultLocale: string;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -185,7 +185,6 @@ export class FrontendService {
|
||||
},
|
||||
dataTables: {
|
||||
maxSize: this.globalConfig.dataTable.maxSize,
|
||||
warningThreshold: this.globalConfig.dataTable.warningThreshold,
|
||||
},
|
||||
publicApi: {
|
||||
enabled: isApiEnabled(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -112,7 +112,6 @@ export const defaultSettings: FrontendSettings = {
|
||||
},
|
||||
dataTables: {
|
||||
maxSize: 0,
|
||||
warningThreshold: 0,
|
||||
},
|
||||
workflowCallerPolicyDefaultOption: 'any',
|
||||
workflowTagsDisabled: false,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user