diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index ab61e257ec..e07b548158 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -338,6 +338,10 @@ export class License { ); } + getConsumerId() { + return this.manager?.getConsumerId() ?? 'unknown'; + } + // Helper functions for computed data getUsersLimit() { return this.getFeatureValue(LICENSE_QUOTAS.USERS_LIMIT) ?? UNLIMITED_LICENSE_QUOTA; diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index eb5e364846..0f7a9167e1 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import { Container, Service } from 'typedi'; import uniq from 'lodash/uniq'; import { createWriteStream } from 'fs'; @@ -83,6 +84,8 @@ export class FrontendService { } this.settings = { + isDocker: this.isDocker(), + databaseType: config.getEnv('database.type'), previewMode: process.env.N8N_PREVIEW_MODE === 'true', endpointForm: config.getEnv('endpoints.form'), endpointFormTest: config.getEnv('endpoints.formTest'), @@ -92,6 +95,7 @@ export class FrontendService { saveDataErrorExecution: config.getEnv('executions.saveDataOnError'), saveDataSuccessExecution: config.getEnv('executions.saveDataOnSuccess'), saveManualExecutions: config.getEnv('executions.saveDataManualExecutions'), + saveExecutionProgress: config.getEnv('executions.saveExecutionProgress'), executionTimeout: config.getEnv('executions.timeout'), maxExecutionTimeout: config.getEnv('executions.maxTimeout'), workflowCallerPolicyDefaultOption: config.getEnv('workflows.callerPolicyDefaultOption'), @@ -99,7 +103,9 @@ export class FrontendService { urlBaseWebhook: this.urlService.getWebhookBaseUrl(), urlBaseEditor: instanceBaseUrl, binaryDataMode: config.getEnv('binaryDataManager.mode'), + nodeJsVersion: process.version.replace(/^v/, ''), versionCli: '', + concurrency: config.getEnv('executions.concurrency.productionLimit'), authCookie: { secure: config.getEnv('secure_cookie'), }, @@ -196,6 +202,7 @@ export class FrontendService { }, hideUsagePage: config.getEnv('hideUsagePage'), license: { + consumerId: 'unknown', environment: config.getEnv('license.tenantId') === 1 ? 'production' : 'staging', }, variables: { @@ -218,6 +225,14 @@ export class FrontendService { pruneTime: -1, licensePruneTime: -1, }, + pruning: { + isEnabled: config.getEnv('executions.pruneData'), + maxAge: config.getEnv('executions.pruneDataMaxAge'), + maxCount: config.getEnv('executions.pruneDataMaxCount'), + }, + security: { + blockFileAccessToN8nFiles: config.getEnv('security.blockFileAccessToN8nFiles'), + }, }; } @@ -269,6 +284,9 @@ export class FrontendService { const isS3Available = config.getEnv('binaryDataManager.availableModes').includes('s3'); const isS3Licensed = this.license.isBinaryDataS3Licensed(); + this.settings.license.planName = this.license.getPlanName(); + this.settings.license.consumerId = this.license.getConsumerId(); + // refresh enterprise status Object.assign(this.settings.enterprise, { sharing: this.license.isSharingEnabled(), @@ -368,4 +386,20 @@ export class FrontendService { } } } + + /** + * Whether this instance is running inside a Docker container. + * + * Based on: https://github.com/sindresorhus/is-docker + */ + private isDocker() { + try { + return ( + fs.existsSync('/.dockerenv') || + fs.readFileSync('/proc/self/cgroup', 'utf8').includes('docker') + ); + } catch { + return false; + } + } } diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 8b8565c9f6..c58ecd7c4b 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1123,7 +1123,7 @@ export interface RootState { urlBaseEditor: string; instanceId: string; isNpmAvailable: boolean; - binaryDataMode: string; + binaryDataMode: 'default' | 'filesystem' | 's3'; } export interface NodeMetadataMap { @@ -1380,9 +1380,10 @@ export interface ISettingsState { enabled: boolean; }; onboardingCallPromptEnabled: boolean; - saveDataErrorExecution: string; - saveDataSuccessExecution: string; + saveDataErrorExecution: WorkflowSettings.SaveDataExecution; + saveDataSuccessExecution: WorkflowSettings.SaveDataExecution; saveManualExecutions: boolean; + saveDataProgressExecution: boolean; } export type NodeTypesByTypeNameAndVersion = { diff --git a/packages/editor-ui/src/__tests__/defaults.ts b/packages/editor-ui/src/__tests__/defaults.ts index b1a0e0dec8..ec67a2517f 100644 --- a/packages/editor-ui/src/__tests__/defaults.ts +++ b/packages/editor-ui/src/__tests__/defaults.ts @@ -1,6 +1,13 @@ import type { IN8nUISettings } from 'n8n-workflow'; export const defaultSettings: IN8nUISettings = { + databaseType: 'sqlite', + isDocker: false, + pruning: { + isEnabled: false, + maxAge: 0, + maxCount: 0, + }, allowedModules: {}, communityNodesEnabled: false, defaultLocale: '', @@ -40,7 +47,7 @@ export const defaultSettings: IN8nUISettings = { hiringBannerEnabled: false, instanceId: '', isNpmAvailable: false, - license: { environment: 'development' }, + license: { environment: 'development', consumerId: 'unknown' }, logLevel: 'info', maxExecutionTimeout: 0, oauthCallbackUrls: { oauth1: '', oauth2: '' }, @@ -60,6 +67,7 @@ export const defaultSettings: IN8nUISettings = { saveDataErrorExecution: 'DEFAULT', saveDataSuccessExecution: 'DEFAULT', saveManualExecutions: false, + saveExecutionProgress: false, sso: { ldap: { loginEnabled: false, loginLabel: '' }, saml: { loginEnabled: false, loginLabel: '' }, @@ -81,6 +89,8 @@ export const defaultSettings: IN8nUISettings = { quota: 10, }, versionCli: '', + nodeJsVersion: '', + concurrency: -1, versionNotifications: { enabled: true, endpoint: '', @@ -113,4 +123,7 @@ export const defaultSettings: IN8nUISettings = { pruneTime: 0, licensePruneTime: 0, }, + security: { + blockFileAccessToN8nFiles: false, + }, }; diff --git a/packages/editor-ui/src/__tests__/utils.ts b/packages/editor-ui/src/__tests__/utils.ts index 7f6e14e24f..4e94ec57f4 100644 --- a/packages/editor-ui/src/__tests__/utils.ts +++ b/packages/editor-ui/src/__tests__/utils.ts @@ -68,6 +68,7 @@ export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = { onboardingCallPromptEnabled: false, saveDataErrorExecution: 'all', saveDataSuccessExecution: 'all', + saveDataProgressExecution: false, saveManualExecutions: false, }; diff --git a/packages/editor-ui/src/components/AboutModal.vue b/packages/editor-ui/src/components/AboutModal.vue index 1b61aabf67..1aeeed0fb5 100644 --- a/packages/editor-ui/src/components/AboutModal.vue +++ b/packages/editor-ui/src/components/AboutModal.vue @@ -42,6 +42,16 @@ {{ rootStore.instanceId }} + + + {{ $locale.baseText('about.debug.title') }} + + + + {{ $locale.baseText('about.debug.message') }} + + + @@ -66,6 +76,9 @@ import Modal from './Modal.vue'; import { ABOUT_MODAL_KEY } from '../constants'; import { useSettingsStore } from '@/stores/settings.store'; import { useRootStore } from '@/stores/root.store'; +import { useToast } from '@/composables/useToast'; +import { useClipboard } from '@/composables/useClipboard'; +import { useDebugInfo } from '@/composables/useDebugInfo'; export default defineComponent({ name: 'About', @@ -85,6 +98,15 @@ export default defineComponent({ closeDialog() { this.modalBus.emit('close'); }, + async copyDebugInfoToClipboard() { + useToast().showToast({ + title: this.$locale.baseText('about.debug.toast.title'), + message: this.$locale.baseText('about.debug.toast.message'), + type: 'info', + duration: 5000, + }); + await useClipboard().copy(useDebugInfo().generateDebugInfo()); + }, }, }); diff --git a/packages/editor-ui/src/composables/useDebugInfo.ts b/packages/editor-ui/src/composables/useDebugInfo.ts new file mode 100644 index 0000000000..97ca75b1ee --- /dev/null +++ b/packages/editor-ui/src/composables/useDebugInfo.ts @@ -0,0 +1,148 @@ +import { useSettingsStore } from '@/stores/settings.store'; +import type { WorkflowSettings } from 'n8n-workflow'; + +type DebugInfo = { + core: { + n8nVersion: string; + platform: 'docker (cloud)' | 'docker (self-hosted)' | 'npm'; + nodeJsVersion: string; + database: 'sqlite' | 'mysql' | 'mariadb' | 'postgres'; + executionMode: 'regular' | 'scaling'; + license: 'community' | 'enterprise (production)' | 'enterprise (sandbox)'; + consumerId: string; + concurrency: number; + }; + storage: { + success: WorkflowSettings.SaveDataExecution; + error: WorkflowSettings.SaveDataExecution; + progress: boolean; + manual: boolean; + binaryMode: 'memory' | 'filesystem' | 's3'; + }; + pruning: + | { + enabled: false; + } + | { + enabled: true; + maxAge: `${number} hours`; + maxCount: `${number} executions`; + }; + /** + * Reported only if insecure settings are found. + */ + security?: { + secureCookie?: boolean; + blockFileAccessToN8nFiles?: boolean; + }; +}; + +export function useDebugInfo() { + const store = useSettingsStore(); + + const coreInfo = () => { + return { + n8nVersion: store.versionCli, + platform: + store.isDocker && store.deploymentType === 'cloud' + ? 'docker (cloud)' + : store.isDocker + ? 'docker (self-hosted)' + : 'npm', + nodeJsVersion: store.nodeJsVersion, + database: + store.databaseType === 'postgresdb' + ? 'postgres' + : store.databaseType === 'mysqldb' + ? 'mysql' + : store.databaseType, + executionMode: store.isQueueModeEnabled ? 'scaling' : 'regular', + concurrency: store.settings.concurrency, + license: + store.planName === 'Community' + ? (store.planName.toLowerCase() as 'community') + : store.settings.license.environment === 'production' + ? 'enterprise (production)' + : 'enterprise (sandbox)', + consumerId: store.consumerId, + } as const; + }; + + const storageInfo = (): DebugInfo['storage'] => { + return { + success: store.saveDataSuccessExecution, + error: store.saveDataErrorExecution, + progress: store.saveDataProgressExecution, + manual: store.saveManualExecutions, + binaryMode: store.binaryDataMode === 'default' ? 'memory' : store.binaryDataMode, + }; + }; + + const pruningInfo = () => { + if (!store.pruning.isEnabled) return { enabled: false } as const; + + return { + enabled: true, + maxAge: `${store.pruning.maxAge} hours`, + maxCount: `${store.pruning.maxCount} executions`, + } as const; + }; + + const securityInfo = () => { + const info: DebugInfo['security'] = {}; + + if (!store.security.blockFileAccessToN8nFiles) info.blockFileAccessToN8nFiles = false; + if (!store.security.secureCookie) info.secureCookie = false; + + if (Object.keys(info).length === 0) return; + + return info; + }; + + const gatherDebugInfo = () => { + const debugInfo: DebugInfo = { + core: coreInfo(), + storage: storageInfo(), + pruning: pruningInfo(), + }; + + const security = securityInfo(); + + if (security) debugInfo.security = security; + + return debugInfo; + }; + + const toMarkdown = (debugInfo: DebugInfo): string => { + let markdown = '# Debug info\n\n'; + + for (const sectionKey in debugInfo) { + markdown += `## ${sectionKey}\n\n`; + + const section = debugInfo[sectionKey as keyof DebugInfo]; + + if (!section) continue; + + for (const itemKey in section) { + const itemValue = section[itemKey as keyof typeof section]; + markdown += `- ${itemKey}: ${itemValue}\n`; + } + + markdown += '\n'; + } + + return markdown; + }; + + const appendTimestamp = (markdown: string) => { + return `${markdown}Generated at: ${new Date().toISOString()}`; + }; + + const generateDebugInfo = () => { + return appendTimestamp(toMarkdown(gatherDebugInfo())); + }; + + return { + generateDebugInfo, + }; +} diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 16eebd55d9..ead985dcd2 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -78,6 +78,10 @@ "about.n8nVersion": "n8n Version", "about.sourceCode": "Source Code", "about.instanceID": "Instance ID", + "about.debug.title": "Debug", + "about.debug.message": "Copy debug information", + "about.debug.toast.title": "Debug info", + "about.debug.toast.message": "Copied debug info to clipboard", "askAi.dialog.title": "'Ask AI' is almost ready", "askAi.dialog.body": "We’re still applying the finishing touches. Soon, you will be able to automatically generate code from simple text prompts. Join the waitlist to get early access to this feature.", "askAi.dialog.signup": "Join Waitlist", diff --git a/packages/editor-ui/src/stores/settings.store.ts b/packages/editor-ui/src/stores/settings.store.ts index d0f9be44a2..6e9ae2bbdb 100644 --- a/packages/editor-ui/src/stores/settings.store.ts +++ b/packages/editor-ui/src/stores/settings.store.ts @@ -68,15 +68,50 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, { saveDataErrorExecution: 'all', saveDataSuccessExecution: 'all', saveManualExecutions: false, + saveDataProgressExecution: false, }), getters: { + isDocker(): boolean { + return this.settings.isDocker; + }, + databaseType(): 'sqlite' | 'mariadb' | 'mysqldb' | 'postgresdb' { + return this.settings.databaseType; + }, + planName(): string { + return this.settings.license.planName ?? 'Community'; + }, + consumerId(): string { + return this.settings.license.consumerId; + }, + binaryDataMode(): 'default' | 'filesystem' | 's3' { + return this.settings.binaryDataMode; + }, + pruning(): { isEnabled: boolean; maxAge: number; maxCount: number } { + return this.settings.pruning; + }, + security(): { + blockFileAccessToN8nFiles: boolean; + secureCookie: boolean; + } { + return { + blockFileAccessToN8nFiles: this.settings.security.blockFileAccessToN8nFiles, + secureCookie: this.settings.authCookie.secure, + }; + }, isEnterpriseFeatureEnabled() { return (feature: EnterpriseEditionFeatureValue): boolean => Boolean(this.settings.enterprise?.[feature]); }, + versionCli(): string { return this.settings.versionCli; }, + nodeJsVersion(): string { + return this.settings.nodeJsVersion; + }, + concurrency(): number { + return this.settings.concurrency; + }, isPublicApiEnabled(): boolean { return this.api.enabled; }, @@ -269,6 +304,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, { this.setAllowedModules(settings.allowedModules); this.setSaveDataErrorExecution(settings.saveDataErrorExecution); this.setSaveDataSuccessExecution(settings.saveDataSuccessExecution); + this.setSaveDataProgressExecution(settings.saveExecutionProgress); this.setSaveManualExecutions(settings.saveManualExecutions); rootStore.setUrlBaseWebhook(settings.urlBaseWebhook); @@ -357,15 +393,18 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, { const rootStore = useRootStore(); return await runLdapSync(rootStore.restApiContext, data); }, - setSaveDataErrorExecution(newValue: string) { + setSaveDataErrorExecution(newValue: WorkflowSettings.SaveDataExecution) { this.saveDataErrorExecution = newValue; }, - setSaveDataSuccessExecution(newValue: string) { + setSaveDataSuccessExecution(newValue: WorkflowSettings.SaveDataExecution) { this.saveDataSuccessExecution = newValue; }, setSaveManualExecutions(saveManualExecutions: boolean) { this.saveManualExecutions = saveManualExecutions; }, + setSaveDataProgressExecution(newValue: boolean) { + this.saveDataProgressExecution = newValue; + }, async getTimezones(): Promise { const rootStore = useRootStore(); return await makeRestApiRequest(rootStore.restApiContext, 'GET', '/options/timezones'); diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 929d78d13e..2b05f5fa04 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2560,6 +2560,8 @@ export type ExpressionEvaluatorType = 'tmpl' | 'tournament'; export type N8nAIProviderType = 'openai' | 'unknown'; export interface IN8nUISettings { + isDocker: boolean; + databaseType: 'sqlite' | 'mariadb' | 'mysqldb' | 'postgresdb'; endpointForm: string; endpointFormTest: string; endpointFormWaiting: string; @@ -2568,6 +2570,7 @@ export interface IN8nUISettings { saveDataErrorExecution: WorkflowSettings.SaveDataExecution; saveDataSuccessExecution: WorkflowSettings.SaveDataExecution; saveManualExecutions: boolean; + saveExecutionProgress: boolean; executionTimeout: number; maxExecutionTimeout: number; workflowCallerPolicyDefaultOption: WorkflowSettings.CallerPolicy; @@ -2579,10 +2582,12 @@ export interface IN8nUISettings { urlBaseWebhook: string; urlBaseEditor: string; versionCli: string; + nodeJsVersion: string; + concurrency: number; authCookie: { secure: boolean; }; - binaryDataMode: string; + binaryDataMode: 'default' | 'filesystem' | 's3'; releaseChannel: 'stable' | 'beta' | 'nightly' | 'dev'; n8nMetadata?: { userId?: string; @@ -2658,6 +2663,8 @@ export interface IN8nUISettings { }; hideUsagePage: boolean; license: { + planName?: string; + consumerId: string; environment: 'development' | 'production' | 'staging'; }; variables: { @@ -2683,6 +2690,14 @@ export interface IN8nUISettings { pruneTime: number; licensePruneTime: number; }; + pruning: { + isEnabled: boolean; + maxAge: number; + maxCount: number; + }; + security: { + blockFileAccessToN8nFiles: boolean; + }; } export interface SecretsHelpersBase {