diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/index.ts b/packages/frontend/@n8n/frontend-module-sdk/src/index.ts
index c5423a30cc2..22e30304937 100644
--- a/packages/frontend/@n8n/frontend-module-sdk/src/index.ts
+++ b/packages/frontend/@n8n/frontend-module-sdk/src/index.ts
@@ -6,4 +6,5 @@ export * as modalRegistry from './registries/modalRegistry';
export * from './registries/resourceRegistry';
export * as pushHandlerRegistry from './registries/pushHandlerRegistry';
export * as commandRegistry from './registries/commandRegistry';
+export * as componentRegistry from './registries/componentRegistry';
export * as parameterInputRegistry from './registries/parameterInputRegistry';
diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/registries/componentRegistry.test.ts b/packages/frontend/@n8n/frontend-module-sdk/src/registries/componentRegistry.test.ts
new file mode 100644
index 00000000000..36803181f2a
--- /dev/null
+++ b/packages/frontend/@n8n/frontend-module-sdk/src/registries/componentRegistry.test.ts
@@ -0,0 +1,73 @@
+import { computed, defineComponent } from 'vue';
+
+import * as componentRegistry from './componentRegistry';
+
+const First = defineComponent({ template: '
first
' });
+const Second = defineComponent({ template: 'second
' });
+
+describe('componentRegistry', () => {
+ beforeEach(() => {
+ componentRegistry.clear();
+ });
+
+ it('resolves a registered slot', () => {
+ componentRegistry.register('project-filter', First);
+
+ expect(componentRegistry.get('project-filter')).toBe(First);
+ expect(componentRegistry.has('project-filter')).toBe(true);
+ });
+
+ it('resolves an unregistered slot to undefined', () => {
+ expect(componentRegistry.get('project-filter')).toBeUndefined();
+ expect(componentRegistry.has('project-filter')).toBe(false);
+ });
+
+ it('treats re-registering the same component as a no-op', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ componentRegistry.register('project-filter', First);
+ componentRegistry.register('project-filter', First);
+
+ expect(componentRegistry.get('project-filter')).toBe(First);
+ expect(warn).not.toHaveBeenCalled();
+ });
+
+ it('keeps the first component and warns when a second claims the slot', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ componentRegistry.register('project-filter', First);
+ componentRegistry.register('project-filter', Second);
+
+ expect(componentRegistry.get('project-filter')).toBe(First);
+ expect(warn).toHaveBeenCalledWith(
+ 'Component slot "project-filter" is already registered. Skipping.',
+ );
+ });
+
+ // The registry is `shallowReactive` so a module can resolve a slot with a plain
+ // `computed`. The shell registers slots lazily, after a module may already have
+ // read an empty slot — swapping in a plain `Map` would break that silently.
+ it('makes a computed reader see a later registration', () => {
+ const slot = computed(() => componentRegistry.get('project-filter'));
+ expect(slot.value).toBeUndefined();
+
+ componentRegistry.register('project-filter', First);
+
+ expect(slot.value).toBe(First);
+ });
+
+ it('makes a computed reader see an unregister', () => {
+ componentRegistry.register('project-filter', First);
+ const slot = computed(() => componentRegistry.get('project-filter'));
+ expect(slot.value).toBe(First);
+
+ componentRegistry.unregister('project-filter');
+
+ expect(slot.value).toBeUndefined();
+ });
+
+ it('frees the slot on unregister', () => {
+ componentRegistry.register('project-filter', First);
+ componentRegistry.unregister('project-filter');
+
+ expect(componentRegistry.get('project-filter')).toBeUndefined();
+ });
+});
diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/registries/componentRegistry.ts b/packages/frontend/@n8n/frontend-module-sdk/src/registries/componentRegistry.ts
new file mode 100644
index 00000000000..8aad304c622
--- /dev/null
+++ b/packages/frontend/@n8n/frontend-module-sdk/src/registries/componentRegistry.ts
@@ -0,0 +1,42 @@
+import { shallowReactive, type Component } from 'vue';
+
+import type { ModuleComponentSlot } from '../types/component';
+
+/**
+ * Shell-hosted components, keyed by slot. The shell registers; modules read.
+ *
+ * Shallow-reactive so a module can resolve a slot with a plain `computed`, and
+ * shallow specifically so a component is never turned into a reactive object.
+ *
+ * A slot with nothing registered resolves to `undefined`, and the module renders
+ * nothing for it. That is the correct behavior in a module's own test run, where
+ * no shell exists — a test that needs the slot registers a stub.
+ */
+const components = shallowReactive(new Map());
+
+export function register(slot: ModuleComponentSlot, component: Component): void {
+ const existing = components.get(slot);
+ if (existing && existing !== component) {
+ console.warn(`Component slot "${slot}" is already registered. Skipping.`);
+ return;
+ }
+
+ components.set(slot, component);
+}
+
+export function get(slot: ModuleComponentSlot): Component | undefined {
+ return components.get(slot);
+}
+
+export function has(slot: ModuleComponentSlot): boolean {
+ return components.has(slot);
+}
+
+export function unregister(slot: ModuleComponentSlot): void {
+ components.delete(slot);
+}
+
+/** Test isolation only. */
+export function clear(): void {
+ components.clear();
+}
diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/types/component.ts b/packages/frontend/@n8n/frontend-module-sdk/src/types/component.ts
new file mode 100644
index 00000000000..58403a1d370
--- /dev/null
+++ b/packages/frontend/@n8n/frontend-module-sdk/src/types/component.ts
@@ -0,0 +1,22 @@
+/**
+ * UI a module renders but does not own.
+ *
+ * Each id names one shell-hosted component with a fixed contract. The union is
+ * closed on purpose: this is not a generic slot system, and adding an id is a
+ * reviewed change to the module contract, not a local decision.
+ *
+ * `project-filter` — a project picker. The project list, the permissions that
+ * decide between local and remote search, and the search itself all live in
+ * `features/collaboration/projects`, which sits above the module layer. A module
+ * that needs to filter by project renders this instead of importing any of it.
+ */
+export type ModuleComponentSlot = 'project-filter';
+
+/**
+ * The `v-model` value of the `project-filter` slot. `null` means "all projects".
+ *
+ * Deliberately narrow: `id` is all a consumer needs, and the host keeps the full
+ * project object. A consumer writes only `null` (to clear); every non-null value
+ * originates in the host.
+ */
+export type SlotProjectSelection = { id: string } | null;
diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/types/index.ts b/packages/frontend/@n8n/frontend-module-sdk/src/types/index.ts
index 13c9b1405ef..2e5a82a31ba 100644
--- a/packages/frontend/@n8n/frontend-module-sdk/src/types/index.ts
+++ b/packages/frontend/@n8n/frontend-module-sdk/src/types/index.ts
@@ -1,3 +1,4 @@
+export type * from './component';
export type * from './descriptor';
export type * from './modal';
export type * from './parameterInput';
diff --git a/packages/frontend/@n8n/frontend-vite-config/index.ts b/packages/frontend/@n8n/frontend-vite-config/index.ts
index 21368a3097a..9f0a2d0a987 100644
--- a/packages/frontend/@n8n/frontend-vite-config/index.ts
+++ b/packages/frontend/@n8n/frontend-vite-config/index.ts
@@ -44,6 +44,7 @@ export const sourcePackages = [
* The module tsconfig base holds that boundary.
*/
export const modulePackages: Array<{ name: string; dir: string; entry?: boolean }> = [
+ { name: '@n8n/frontend-module-insights', dir: 'modules/insights/frontend' },
{ name: '@n8n/frontend-module-instance-registry', dir: 'modules/instance-registry/frontend' },
{ name: '@n8n/frontend-module-otel', dir: 'modules/otel/frontend' },
];
diff --git a/packages/frontend/@n8n/stores/src/composables/useBasePageRedirectionHelper.test.ts b/packages/frontend/@n8n/stores/src/composables/useBasePageRedirectionHelper.test.ts
index 0c2bbbce598..dccbd7ee995 100644
--- a/packages/frontend/@n8n/stores/src/composables/useBasePageRedirectionHelper.test.ts
+++ b/packages/frontend/@n8n/stores/src/composables/useBasePageRedirectionHelper.test.ts
@@ -6,6 +6,10 @@ import { createPinia, setActivePinia } from 'pinia';
import { mock } from 'vitest-mock-extended';
import { useCloudPlanStore } from '../cloudPlan.store';
+import {
+ clearDefaultUpgradeRedirectGuard,
+ setDefaultUpgradeRedirectGuard,
+} from '../registries/upgradeRedirectGuard';
import { useSettingsStore } from '../settings.store';
import { useUsersStore } from '../users.store';
import { useVersionsStore } from '../versions.store';
@@ -247,6 +251,78 @@ describe('useBasePageRedirectionHelper', () => {
});
});
+ // A module package cannot pass the shell's guard, so it omits the argument and
+ // gets whatever the shell registered. Nothing registered means fail-open.
+ describe('goToUpgrade with no guard argument', () => {
+ beforeEach(() => {
+ usersStore.addUsers([{ id: '1', isPending: false, role: ROLE.Owner }]);
+ usersStore.currentUserId = '1';
+
+ settingsStore.setSettings(settingsFor('cloud'));
+ clearDefaultUpgradeRedirectGuard();
+ });
+
+ afterEach(() => {
+ clearDefaultUpgradeRedirectGuard();
+ });
+
+ test('consults the registered default guard and aborts when it resolves false', async () => {
+ const telemetry = useTelemetry();
+ const initialHref = location.href;
+ const guard = vi.fn().mockResolvedValue(false);
+ setDefaultUpgradeRedirectGuard(guard);
+
+ await useBasePageRedirectionHelper().goToUpgrade(
+ 'advanced-permissions',
+ 'upgrade-api',
+ 'redirect',
+ );
+
+ expect(guard).toHaveBeenCalledTimes(1);
+ expect(location.href).toBe(initialHref);
+ expect(telemetry.track).not.toHaveBeenCalled();
+ });
+
+ test('consults the registered default guard and proceeds when it resolves true', async () => {
+ const guard = vi.fn().mockResolvedValue(true);
+ setDefaultUpgradeRedirectGuard(guard);
+
+ await useBasePageRedirectionHelper().goToUpgrade(
+ 'advanced-permissions',
+ 'upgrade-api',
+ 'redirect',
+ );
+
+ expect(guard).toHaveBeenCalledTimes(1);
+ expect(location.href).toContain('utm_campaign=upgrade-api');
+ });
+
+ test('proceeds when no guard is registered at all', async () => {
+ await useBasePageRedirectionHelper().goToUpgrade(
+ 'advanced-permissions',
+ 'upgrade-api',
+ 'redirect',
+ );
+
+ expect(location.href).toContain('utm_campaign=upgrade-api');
+ });
+
+ test('prefers an explicitly passed guard over the registered default', async () => {
+ const registered = vi.fn().mockResolvedValue(true);
+ const passed = vi.fn().mockResolvedValue(false);
+ setDefaultUpgradeRedirectGuard(registered);
+
+ await useBasePageRedirectionHelper({ guard: passed }).goToUpgrade(
+ 'advanced-permissions',
+ 'upgrade-api',
+ 'redirect',
+ );
+
+ expect(passed).toHaveBeenCalledTimes(1);
+ expect(registered).not.toHaveBeenCalled();
+ });
+ });
+
describe('goToCloudDashboard', () => {
const reservedTab = { close: vi.fn(), location: { href: '' }, opener: {} as Window | null };
const CONNECT_PATH = '/manage/connect';
diff --git a/packages/frontend/@n8n/stores/src/composables/useBasePageRedirectionHelper.ts b/packages/frontend/@n8n/stores/src/composables/useBasePageRedirectionHelper.ts
index b840b83d2ee..83776ae8789 100644
--- a/packages/frontend/@n8n/stores/src/composables/useBasePageRedirectionHelper.ts
+++ b/packages/frontend/@n8n/stores/src/composables/useBasePageRedirectionHelper.ts
@@ -2,6 +2,7 @@ import { useTelemetry } from '@n8n/composables/useTelemetry';
import { N8N_PRICING_PAGE_URL } from '@n8n/frontend-constants/urls';
import { useCloudPlanStore } from '../cloudPlan.store';
+import { getDefaultUpgradeRedirectGuard } from '../registries/upgradeRedirectGuard';
import { useSettingsStore } from '../settings.store';
import type { CloudUpdateLinkSourceType, UTMCampaign } from '../types/pageRedirection';
import { useUsersStore } from '../users.store';
@@ -17,14 +18,15 @@ export type UpgradeRedirectGuard = () => Promise;
/**
* Injectable page-redirection composable. The app-facing `usePageRedirectionHelper`
* wraps this and supplies the guard, which keeps this base free of any feature
- * dependency.
+ * dependency. A caller that cannot reach the shell — a module package — omits it
+ * and gets the guard the shell registered (see `registries/upgradeRedirectGuard`).
*
* It lives in `@n8n/stores` rather than `@n8n/composables` because its body is
* store orchestration end to end — all four stores it reads are in this package,
* and `@n8n/composables` sits *below* the stores tier (see that package's
* `packageBoundary.test.ts`), so it cannot reach them.
*/
-export function useBasePageRedirectionHelper({ guard }: { guard: UpgradeRedirectGuard }) {
+export function useBasePageRedirectionHelper({ guard }: { guard?: UpgradeRedirectGuard } = {}) {
const usersStore = useUsersStore();
const cloudPlanStore = useCloudPlanStore();
const versionsStore = useVersionsStore();
@@ -102,7 +104,7 @@ export function useBasePageRedirectionHelper({ guard }: { guard: UpgradeRedirect
utm_campaign: UTMCampaign,
mode: 'open' | 'redirect' = 'open',
) => {
- const shouldProceed = await guard();
+ const shouldProceed = await (guard ?? getDefaultUpgradeRedirectGuard())();
if (!shouldProceed) return;
const { usageLeft, trialDaysLeft, userIsTrialing } = cloudPlanStore;
diff --git a/packages/frontend/@n8n/stores/src/registries/upgradeRedirectGuard.test.ts b/packages/frontend/@n8n/stores/src/registries/upgradeRedirectGuard.test.ts
new file mode 100644
index 00000000000..d5e0a359703
--- /dev/null
+++ b/packages/frontend/@n8n/stores/src/registries/upgradeRedirectGuard.test.ts
@@ -0,0 +1,30 @@
+import {
+ clearDefaultUpgradeRedirectGuard,
+ getDefaultUpgradeRedirectGuard,
+ setDefaultUpgradeRedirectGuard,
+} from './upgradeRedirectGuard';
+
+describe('upgradeRedirectGuard', () => {
+ beforeEach(() => {
+ clearDefaultUpgradeRedirectGuard();
+ });
+
+ it('proceeds when nothing is registered', async () => {
+ await expect(getDefaultUpgradeRedirectGuard()()).resolves.toBe(true);
+ });
+
+ it('returns the registered guard', async () => {
+ const guard = vi.fn().mockResolvedValue(false);
+ setDefaultUpgradeRedirectGuard(guard);
+
+ await expect(getDefaultUpgradeRedirectGuard()()).resolves.toBe(false);
+ expect(guard).toHaveBeenCalledTimes(1);
+ });
+
+ it('lets a later registration replace an earlier one', async () => {
+ setDefaultUpgradeRedirectGuard(async () => await Promise.resolve(false));
+ setDefaultUpgradeRedirectGuard(async () => await Promise.resolve(true));
+
+ await expect(getDefaultUpgradeRedirectGuard()()).resolves.toBe(true);
+ });
+});
diff --git a/packages/frontend/@n8n/stores/src/registries/upgradeRedirectGuard.ts b/packages/frontend/@n8n/stores/src/registries/upgradeRedirectGuard.ts
new file mode 100644
index 00000000000..18f43a4473b
--- /dev/null
+++ b/packages/frontend/@n8n/stores/src/registries/upgradeRedirectGuard.ts
@@ -0,0 +1,29 @@
+import type { UpgradeRedirectGuard } from '../composables/useBasePageRedirectionHelper';
+
+/**
+ * The guard `useBasePageRedirectionHelper` consults when a caller supplies none.
+ *
+ * A module package cannot reach the shell, so it cannot pass the app's guard (the
+ * AI-builder streaming confirmation) itself. The shell registers that guard here
+ * once at init; a module then calls the base composable with no argument and gets
+ * the same confirmation an in-shell caller gets.
+ *
+ * The fallback proceeds, so a test or a boot path that registers nothing behaves
+ * as it did before any guard existed.
+ */
+let registered: UpgradeRedirectGuard | undefined;
+
+const proceed: UpgradeRedirectGuard = async () => await Promise.resolve(true);
+
+export function setDefaultUpgradeRedirectGuard(guard: UpgradeRedirectGuard): void {
+ registered = guard;
+}
+
+export function getDefaultUpgradeRedirectGuard(): UpgradeRedirectGuard {
+ return registered ?? proceed;
+}
+
+/** Test isolation only. */
+export function clearDefaultUpgradeRedirectGuard(): void {
+ registered = undefined;
+}
diff --git a/packages/frontend/editor-ui/eslint.config.mjs b/packages/frontend/editor-ui/eslint.config.mjs
index 0a08b41c569..35d138f5fd7 100644
--- a/packages/frontend/editor-ui/eslint.config.mjs
+++ b/packages/frontend/editor-ui/eslint.config.mjs
@@ -25,6 +25,11 @@ const extractedFeatures = [
message:
'otel is the @n8n/frontend-module-otel package. The shell registers a module through src/app/modules.manifest.ts.',
},
+ {
+ group: ['@/features/execution/insights', '@/features/execution/insights/*'],
+ message:
+ 'insights is the @n8n/frontend-module-insights package. The shell registers a module through src/app/modules.manifest.ts.',
+ },
];
export default defineConfig(
diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json
index 445d707be0a..a80a6165c85 100644
--- a/packages/frontend/editor-ui/package.json
+++ b/packages/frontend/editor-ui/package.json
@@ -53,6 +53,7 @@
"@n8n/design-system": "workspace:*",
"@n8n/eslint-config": "workspace:*",
"@n8n/frontend-constants": "workspace:*",
+ "@n8n/frontend-module-insights": "workspace:*",
"@n8n/frontend-module-instance-registry": "workspace:*",
"@n8n/frontend-module-otel": "workspace:*",
"@n8n/frontend-module-sdk": "workspace:*",
@@ -122,6 +123,7 @@
"dateformat": "^3.0.3",
"element-plus": "catalog:frontend",
"email-providers": "^2.0.1",
+ "eslint": "catalog:",
"eslint-plugin-oxlint": "catalog:",
"esprima-next": "5.8.4",
"fake-indexeddb": "^6.0.0",
diff --git a/packages/frontend/editor-ui/src/app/bootstrapRegistrations.test.ts b/packages/frontend/editor-ui/src/app/bootstrapRegistrations.test.ts
new file mode 100644
index 00000000000..a8724960ab4
--- /dev/null
+++ b/packages/frontend/editor-ui/src/app/bootstrapRegistrations.test.ts
@@ -0,0 +1,27 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+/**
+ * `main.ts` boots the app, so it cannot be imported into a unit test. These
+ * registrations are still load-bearing and silent when missing:
+ *
+ * - drop `registerUpgradeRedirectGuard` and an upgrade CTA raised from a module
+ * package skips the AI-builder confirmation, because the registry fails open;
+ * - drop `registerComponentSlots` and the insights dashboard renders no project
+ * picker, because an unregistered slot resolves to `undefined`.
+ *
+ * Both failures are invisible to every other test, so this asserts the call sites
+ * exist. It is the same shape as `vite/aliases.test.ts`, which reads config files
+ * to hold two of them in step.
+ */
+const mainTs = readFileSync(join(process.cwd(), 'src', 'main.ts'), 'utf8');
+
+describe('main.ts bootstrap registrations', () => {
+ it.each([
+ ['registerUpgradeRedirectGuard', '@/app/upgradeRedirectGuard.manifest'],
+ ['registerComponentSlots', '@/app/componentSlots.manifest'],
+ ])('imports %s from %s and calls it', (fn, from) => {
+ expect(mainTs).toContain(`import { ${fn} } from '${from}';`);
+ expect(mainTs).toMatch(new RegExp(`^${fn}\\(\\);$`, 'm'));
+ });
+});
diff --git a/packages/frontend/editor-ui/src/app/componentSlots.manifest.test.ts b/packages/frontend/editor-ui/src/app/componentSlots.manifest.test.ts
new file mode 100644
index 00000000000..2e392993b2f
--- /dev/null
+++ b/packages/frontend/editor-ui/src/app/componentSlots.manifest.test.ts
@@ -0,0 +1,32 @@
+import { componentRegistry } from '@n8n/frontend-module-sdk';
+
+import { registerComponentSlots } from './componentSlots.manifest';
+
+describe('registerComponentSlots', () => {
+ beforeEach(() => {
+ componentRegistry.clear();
+ });
+
+ afterEach(() => {
+ componentRegistry.clear();
+ });
+
+ it('leaves the project-filter slot empty until it is called', () => {
+ expect(componentRegistry.get('project-filter')).toBeUndefined();
+ });
+
+ it('registers the project-filter slot', () => {
+ registerComponentSlots();
+
+ expect(componentRegistry.get('project-filter')).toBeDefined();
+ });
+
+ it('registers the host lazily, so the projects feature stays out of the boot chunk', () => {
+ registerComponentSlots();
+
+ // An async component wrapper, not the resolved SFC: resolving here would make
+ // `ProjectSharing` and the projects store statically reachable from `main.ts`.
+ const slot = componentRegistry.get('project-filter');
+ expect((slot as { __asyncLoader?: unknown }).__asyncLoader).toBeInstanceOf(Function);
+ });
+});
diff --git a/packages/frontend/editor-ui/src/app/componentSlots.manifest.ts b/packages/frontend/editor-ui/src/app/componentSlots.manifest.ts
new file mode 100644
index 00000000000..436b0d090f6
--- /dev/null
+++ b/packages/frontend/editor-ui/src/app/componentSlots.manifest.ts
@@ -0,0 +1,19 @@
+import { componentRegistry } from '@n8n/frontend-module-sdk';
+import { defineAsyncComponent } from 'vue';
+
+/**
+ * Shell-hosted components that modules render but do not own — the counterpart to
+ * `modals.manifest.ts` for the `componentRegistry` slots.
+ *
+ * Async on purpose: registering these eagerly would pull the owning feature into
+ * the boot chunk (the project filter reaches the projects store and ProjectSharing's
+ * design-system tree), which is exactly what a module renders a slot to avoid.
+ */
+export const registerComponentSlots = () => {
+ componentRegistry.register(
+ 'project-filter',
+ defineAsyncComponent(
+ async () => await import('@/features/collaboration/projects/components/ProjectFilter.vue'),
+ ),
+ );
+};
diff --git a/packages/frontend/editor-ui/src/app/extractionRatchet.test.ts b/packages/frontend/editor-ui/src/app/extractionRatchet.test.ts
new file mode 100644
index 00000000000..86c9dae5722
--- /dev/null
+++ b/packages/frontend/editor-ui/src/app/extractionRatchet.test.ts
@@ -0,0 +1,79 @@
+import { ESLint } from 'eslint';
+
+/**
+ * The extraction ratchet must hold for every file under `src`.
+ *
+ * `no-restricted-imports` is not mergeable across flat-config blocks — a later
+ * block replaces the rule outright. That is exactly how the ratchet was turned off
+ * for `src/features/agents/**` once already: a narrower block set the rule without
+ * spreading `extractedFeatures`. A comment cannot hold that, so this asserts
+ * the *resolved* config rather than the config source.
+ *
+ * Add a block that sets `no-restricted-imports` for some subtree and forgets the
+ * spread, and the matching path below fails.
+ */
+type RestrictedImports = [
+ string | number,
+ { patterns?: Array<{ group?: string[]; message?: string }> },
+];
+
+const EXTRACTED_MODULES = [
+ { group: '@/features/instanceRegistry', package: '@n8n/frontend-module-instance-registry' },
+ { group: '@/features/settings/otel', package: '@n8n/frontend-module-otel' },
+ { group: '@/features/execution/insights', package: '@n8n/frontend-module-insights' },
+];
+
+// One representative path per block in `eslint.config.mjs` that sets the rule, plus
+// plain feature and app paths. `features/agents` is the subtree that regressed.
+const PATHS = [
+ 'src/app/main.ts',
+ 'src/app/views/WorkflowsView.vue',
+ 'src/features/agents/views/AgentsListView.vue',
+ 'src/features/agents/agents.store.ts',
+ 'src/features/credentials/views/CredentialsView.vue',
+ 'src/features/collaboration/projects/projects.routes.ts',
+ 'src/features/ndv/ndv.store.ts',
+];
+
+describe('extraction ratchet', () => {
+ let configs: Map;
+
+ beforeAll(async () => {
+ const eslint = new ESLint({ cwd: process.cwd() });
+ configs = new Map();
+ for (const path of PATHS) {
+ const config = (await eslint.calculateConfigForFile(path)) as {
+ rules?: Record;
+ };
+ configs.set(
+ path,
+ config.rules?.['@typescript-eslint/no-restricted-imports'] as RestrictedImports | undefined,
+ );
+ }
+ }, 60000);
+
+ it.each(PATHS)('restricts every extracted module for %s', (path) => {
+ const rule = configs.get(path);
+ expect(rule, `no-restricted-imports is unset for ${path}`).toBeDefined();
+
+ // ESLint normalises the severity, so `error` arrives as 2.
+ const [severity, options] = rule as RestrictedImports;
+ expect(severity).toBe(2);
+
+ const groups = (options.patterns ?? []).flatMap((pattern) => pattern.group ?? []);
+ for (const { group, package: pkg } of EXTRACTED_MODULES) {
+ expect(groups, `${path} does not restrict ${pkg}'s old path`).toContain(group);
+ }
+ });
+
+ it('keeps the agents-only RunData restriction alongside the ratchet', () => {
+ // The pattern the regression dropped. It proves the narrow block still sets its
+ // own patterns rather than being replaced by the wide one.
+ const rule = configs.get('src/features/agents/views/AgentsListView.vue');
+ const groups = ((rule as RestrictedImports)[1].patterns ?? []).flatMap(
+ (pattern) => pattern.group ?? [],
+ );
+
+ expect(groups).toContain('**/ndv/runData/components/RunData.vue');
+ });
+});
diff --git a/packages/frontend/editor-ui/src/app/modules.manifest.ts b/packages/frontend/editor-ui/src/app/modules.manifest.ts
index be8beaafab1..d481ae7b35a 100644
--- a/packages/frontend/editor-ui/src/app/modules.manifest.ts
+++ b/packages/frontend/editor-ui/src/app/modules.manifest.ts
@@ -1,6 +1,5 @@
import type { FrontendModuleDescription } from '@n8n/frontend-module-sdk';
import { DataTableModule } from '@/features/core/dataTable/module.descriptor';
-import { InsightsModule } from '@/features/execution/insights/module.descriptor';
import { MCPModule } from '@/features/ai/mcpAccess/module.descriptor';
import { ChatModule } from '@/features/ai/chatHub/module.descriptor';
import { InstanceAiModule } from '@/features/ai/instanceAi/module.descriptor';
@@ -8,13 +7,13 @@ import { AgentsModule } from '@/features/agents/module.descriptor';
import { WorkflowReviewsModule } from '@/features/workflow-reviews/module.descriptor';
import { InstanceRegistryModule } from '@n8n/frontend-module-instance-registry';
import { OtelModule } from '@n8n/frontend-module-otel';
+import { InsightsModule } from '@n8n/frontend-module-insights/insights.module';
import { PromotionsModule } from '@/features/integrations/promotions.ee/module.descriptor';
/**
* Hard-coding modules list until we have a dynamic way to load modules.
*/
export const modules: FrontendModuleDescription[] = [
- InsightsModule,
DataTableModule,
MCPModule,
ChatModule,
@@ -23,5 +22,6 @@ export const modules: FrontendModuleDescription[] = [
OtelModule,
WorkflowReviewsModule,
InstanceRegistryModule,
+ InsightsModule,
PromotionsModule,
];
diff --git a/packages/frontend/editor-ui/src/app/upgradeRedirectGuard.manifest.test.ts b/packages/frontend/editor-ui/src/app/upgradeRedirectGuard.manifest.test.ts
new file mode 100644
index 00000000000..8c8d5a4e1c8
--- /dev/null
+++ b/packages/frontend/editor-ui/src/app/upgradeRedirectGuard.manifest.test.ts
@@ -0,0 +1,55 @@
+import {
+ clearDefaultUpgradeRedirectGuard,
+ getDefaultUpgradeRedirectGuard,
+} from '@n8n/stores/registries/upgradeRedirectGuard';
+
+import { registerUpgradeRedirectGuard } from './upgradeRedirectGuard.manifest';
+
+const confirmIfBuilderStreaming = vi.fn();
+
+vi.mock('@/features/ai/assistant/composables/useBuilderStreamingGuard', () => ({
+ get confirmIfBuilderStreaming() {
+ return confirmIfBuilderStreaming;
+ },
+}));
+
+describe('registerUpgradeRedirectGuard', () => {
+ beforeEach(() => {
+ clearDefaultUpgradeRedirectGuard();
+ confirmIfBuilderStreaming.mockReset();
+ });
+
+ afterEach(() => {
+ clearDefaultUpgradeRedirectGuard();
+ });
+
+ it('leaves the registry failing open until it is called', async () => {
+ // The unregistered default proceeds, so an unregistered guard is silent, not loud.
+ await expect(getDefaultUpgradeRedirectGuard()()).resolves.toBe(true);
+ expect(confirmIfBuilderStreaming).not.toHaveBeenCalled();
+ });
+
+ it('registers a guard that delegates to the builder streaming confirmation', async () => {
+ confirmIfBuilderStreaming.mockResolvedValue(false);
+ registerUpgradeRedirectGuard();
+
+ await expect(getDefaultUpgradeRedirectGuard()()).resolves.toBe(false);
+ expect(confirmIfBuilderStreaming).toHaveBeenCalledTimes(1);
+ });
+
+ it('passes a confirmation through', async () => {
+ confirmIfBuilderStreaming.mockResolvedValue(true);
+ registerUpgradeRedirectGuard();
+
+ await expect(getDefaultUpgradeRedirectGuard()()).resolves.toBe(true);
+ });
+
+ it('does not load the guard module until the guard runs', () => {
+ confirmIfBuilderStreaming.mockResolvedValue(true);
+ registerUpgradeRedirectGuard();
+
+ // Registration alone must not reach the builder store: that is what keeps the
+ // builder out of the boot chunk.
+ expect(confirmIfBuilderStreaming).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/frontend/editor-ui/src/app/upgradeRedirectGuard.manifest.ts b/packages/frontend/editor-ui/src/app/upgradeRedirectGuard.manifest.ts
new file mode 100644
index 00000000000..a596f868077
--- /dev/null
+++ b/packages/frontend/editor-ui/src/app/upgradeRedirectGuard.manifest.ts
@@ -0,0 +1,18 @@
+import { setDefaultUpgradeRedirectGuard } from '@n8n/stores/registries/upgradeRedirectGuard';
+
+/**
+ * The upgrade-CTA guard, for callers that cannot import it themselves — a module
+ * package must not reach into `features/`. Without this registration the registry
+ * fails open and an upgrade CTA raised from a module skips the confirmation.
+ *
+ * The guard body loads on the click, not here: it reads the AI builder store, and
+ * importing that eagerly would put the builder in the boot chunk.
+ */
+export const registerUpgradeRedirectGuard = () => {
+ setDefaultUpgradeRedirectGuard(async () => {
+ const { confirmIfBuilderStreaming } = await import(
+ '@/features/ai/assistant/composables/useBuilderStreamingGuard'
+ );
+ return await confirmIfBuilderStreaming();
+ });
+};
diff --git a/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue b/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue
index fef2657f092..3709a9c11ff 100644
--- a/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue
+++ b/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue
@@ -51,7 +51,7 @@ import { useTrialIntroModalStore } from '@/experiments/trialIntroModal/stores/tr
import EmptyStateLayout from '@/app/components/layouts/EmptyStateLayout.vue';
import { useReadyToRunStore } from '@/features/workflows/readyToRun/stores/readyToRun.store';
import { useEmptyStateDetection } from '@/features/workflows/readyToRun/composables/useEmptyStateDetection';
-import { InsightsSummary, useInsightsStore } from '@/features/execution/insights';
+import { InsightsSummary, useInsightsStore } from '@n8n/frontend-module-insights';
import { useWorkflowsEmptyState } from '@/features/workflows/composables/useWorkflowsEmptyState';
import type {
BaseFilters,
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentsListView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentsListView.test.ts
index ccfc47d9ead..1eb1a060845 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentsListView.test.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentsListView.test.ts
@@ -45,8 +45,8 @@ vi.mock('@/features/collaboration/projects/projects.store', () => ({
}),
}));
-vi.mock('@/features/execution/insights', async (importOriginal) => ({
- ...(await importOriginal()),
+vi.mock('@n8n/frontend-module-insights', async (importOriginal) => ({
+ ...(await importOriginal()),
useInsightsStore: () => ({
isSummaryEnabled: false,
weeklySummary: { isLoading: false, state: null },
diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentsListView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentsListView.vue
index b365f44d48f..f869f783278 100644
--- a/packages/frontend/editor-ui/src/features/agents/views/AgentsListView.vue
+++ b/packages/frontend/editor-ui/src/features/agents/views/AgentsListView.vue
@@ -10,7 +10,7 @@ import { useProjectsStore } from '@/features/collaboration/projects/projects.sto
import ProjectHeader from '@/features/collaboration/projects/components/ProjectHeader.vue';
import ResourcesListLayout from '@/app/components/layouts/ResourcesListLayout.vue';
import ResourcesListEmptyState from '@/app/components/layouts/ResourcesListEmptyState.vue';
-import { InsightsSummary, useInsightsStore } from '@/features/execution/insights';
+import { InsightsSummary, useInsightsStore } from '@n8n/frontend-module-insights';
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import {
diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectFilter.slot.test.ts b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectFilter.slot.test.ts
new file mode 100644
index 00000000000..4d158ec6eae
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectFilter.slot.test.ts
@@ -0,0 +1,155 @@
+import type { FrontendModuleSettings } from '@n8n/api-types';
+import { componentRegistry } from '@n8n/frontend-module-sdk';
+import { InsightsModule } from '@n8n/frontend-module-insights/insights.module';
+import { useInsightsStore } from '@n8n/frontend-module-insights';
+import { createTestingPinia } from '@pinia/testing';
+import { screen, waitFor } from '@testing-library/vue';
+import userEvent from '@testing-library/user-event';
+import { reactive } from 'vue';
+import type { Component } from 'vue';
+
+import { createComponentRenderer } from '@/__tests__/render';
+import { defaultSettings, mockedStore } from '@n8n/frontend-test-utils';
+import { getDropdownItems } from '@/__tests__/utils';
+
+import { createProjectListItem } from '../__tests__/utils';
+import { useProjectsStore } from '../projects.store';
+import { registerComponentSlots } from '@/app/componentSlots.manifest';
+
+/**
+ * The `project-filter` slot, end to end, with no stub on either side.
+ *
+ * This is the only place the contract can be checked. `` erases prop
+ * types, so neither package's typecheck sees the other's half: the module passes
+ * `placeholder` and `size` into a host it cannot import, and the host is written
+ * against a module it must not import. Rename a prop on either side and only a test
+ * that renders both together notices.
+ *
+ * Both halves are reached through their public entries — the dashboard through the
+ * descriptor's own route record, so this test widens no package API.
+ */
+const teamProjects = Array.from({ length: 2 }, () => createProjectListItem('team'));
+
+const mockRoute = reactive<{ params: { insightType: string } }>({
+ params: { insightType: 'total' },
+});
+
+vi.mock('vue-router', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useRoute: () => mockRoute,
+}));
+
+const moduleSettings: FrontendModuleSettings = {
+ insights: {
+ summary: false,
+ dashboard: false,
+ earliestDataDate: null,
+ dateRanges: [{ key: 'week', licensed: true, granularity: 'day' }],
+ },
+};
+
+/** The dashboard as the shell reaches it: the descriptor's lazy route component. */
+const loadDashboard = async (): Promise => {
+ const child = InsightsModule.routes?.[0]?.children?.[0];
+ const loader = child?.components?.default ?? child?.component;
+ if (typeof loader !== 'function') throw new Error('insights dashboard route component missing');
+ const loaded = (await (loader as () => Promise<{ default: Component }>)()) as {
+ default?: Component;
+ };
+ return loaded.default ?? (loaded as Component);
+};
+
+describe('project-filter slot, composed', () => {
+ let insightsStore: ReturnType>;
+ let projectsStore: ReturnType>;
+
+ beforeEach(() => {
+ vi.setSystemTime(new Date('2000-12-19T00:00:00.000Z'));
+ mockRoute.params.insightType = 'total';
+
+ createTestingPinia({
+ initialState: { settings: { settings: defaultSettings, moduleSettings } },
+ });
+
+ insightsStore = mockedStore(useInsightsStore);
+ // Both panels off, and `insightType` is not `timeSaved`, so the charts and the
+ // table do not render. This test is about the slot, not the dashboard's content;
+ // `charts.execute` still runs on every selection, which is the round trip below.
+ insightsStore.isSummaryEnabled = false;
+ insightsStore.isDashboardEnabled = false;
+ insightsStore.earliestDataDate = null;
+ insightsStore.dateRanges = [{ key: 'week', licensed: true, granularity: 'day' }];
+ for (const key of ['summary', 'charts', 'table'] as const) {
+ insightsStore[key] = {
+ state: null,
+ isLoading: false,
+ execute: vi.fn(),
+ executeImmediate: vi.fn(),
+ isReady: true,
+ error: null,
+ then: vi.fn(),
+ } as never;
+ }
+
+ projectsStore = mockedStore(useProjectsStore);
+ projectsStore.myProjects = teamProjects;
+ projectsStore.availableProjects = teamProjects;
+ projectsStore.getAvailableProjects.mockResolvedValue();
+ projectsStore.searchProjects.mockResolvedValue({
+ count: teamProjects.length,
+ data: teamProjects,
+ });
+ projectsStore.globalProjectPermissions = { list: true };
+
+ componentRegistry.clear();
+ registerComponentSlots();
+ });
+
+ afterEach(() => {
+ componentRegistry.clear();
+ vi.useRealTimers();
+ });
+
+ it("lands the module's placeholder and size props on the real host", async () => {
+ const renderDashboard = createComponentRenderer(await loadDashboard());
+ renderDashboard({ props: { insightType: 'total' } });
+
+ const select = await screen.findByTestId('project-sharing-select');
+
+ // `insights.dashboard.search.placeholder`, carried across the slot as a prop.
+ // The host sets `inheritAttrs: false`, so a misspelled prop cannot reach the
+ // input as a bare attribute and imitate a working contract — it falls back to
+ // ProjectSharing's own default, 'Select project or user'.
+ const input = select.querySelector('input') as HTMLInputElement;
+ expect(input.placeholder).toBe('All projects');
+ // `size="mini"` crosses the slot; N8nSelect maps it onto element-plus `small`.
+ expect(select.querySelector('.el-select--small')).toBeInTheDocument();
+ // The module styles the picker for its own layout (`.projectSelect` sets its
+ // width), so `class` is forwarded explicitly past `inheritAttrs: false`.
+ const styled = document.querySelector('[class*="projectSelect"]');
+ expect(styled).toBeInTheDocument();
+ expect(styled).toContainElement(select);
+ });
+
+ it('carries a selection from the real host back into the module', async () => {
+ const renderDashboard = createComponentRenderer(await loadDashboard());
+ renderDashboard({ props: { insightType: 'total' } });
+
+ const select = await screen.findByTestId('project-sharing-select');
+ await userEvent.click(select);
+
+ const items = await getDropdownItems(select);
+ const option = [...items].find(
+ (item) => item.querySelector('p')?.textContent?.trim() === teamProjects[0].name,
+ );
+ expect(option).toBeDefined();
+ await userEvent.click(option as Element);
+
+ await waitFor(() => {
+ expect(insightsStore.charts.execute).toHaveBeenCalledWith(
+ 0,
+ expect.objectContaining({ projectId: teamProjects[0].id }),
+ );
+ });
+ });
+}, 30000);
diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectFilter.test.ts b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectFilter.test.ts
new file mode 100644
index 00000000000..181270adbc4
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectFilter.test.ts
@@ -0,0 +1,157 @@
+import { createTestingPinia } from '@pinia/testing';
+import { mockedStore } from '@/__tests__/utils';
+import { createComponentRenderer } from '@/__tests__/render';
+import { getDropdownItems } from '@/__tests__/utils';
+import userEvent from '@testing-library/user-event';
+import { waitFor } from '@testing-library/vue';
+import { setActivePinia } from 'pinia';
+import { defineComponent, ref } from 'vue';
+import type { SlotProjectSelection } from '@n8n/frontend-module-sdk';
+
+import { createProjectListItem } from '../__tests__/utils';
+import { useProjectsStore } from '../projects.store';
+import ProjectFilter from './ProjectFilter.vue';
+
+const renderComponent = createComponentRenderer(ProjectFilter);
+
+let projectsStore: ReturnType>;
+
+const teamProjects = Array.from({ length: 2 }, () => createProjectListItem('team'));
+// A personal project's name is the owner's address in angle brackets.
+const personalProject = { ...createProjectListItem('personal'), name: '' };
+const projects = [personalProject, ...teamProjects];
+
+describe('ProjectFilter', () => {
+ beforeEach(() => {
+ setActivePinia(createTestingPinia());
+
+ projectsStore = mockedStore(useProjectsStore);
+ projectsStore.myProjects = projects;
+ projectsStore.availableProjects = projects;
+ projectsStore.getAvailableProjects.mockResolvedValue();
+ projectsStore.searchProjects.mockResolvedValue({ count: projects.length, data: projects });
+ projectsStore.globalProjectPermissions = { list: true };
+ });
+
+ it('emits the selected project id', async () => {
+ const { emitted, getByTestId } = renderComponent({ props: { modelValue: null } });
+
+ const select = getByTestId('project-sharing-select');
+ await userEvent.click(select);
+
+ const items = await getDropdownItems(select);
+ const match = [...items].find(
+ (item) => item.querySelector('p')?.textContent?.trim() === teamProjects[0].name,
+ );
+ expect(match).toBeDefined();
+ await userEvent.click(match as Element);
+
+ await waitFor(() => {
+ expect(emitted('update:modelValue')).toEqual([[{ id: teamProjects[0].id }]]);
+ });
+ });
+
+ it('omits personal projects from the options', async () => {
+ const { getByTestId } = renderComponent({ props: { modelValue: null } });
+
+ const select = getByTestId('project-sharing-select');
+ await userEvent.click(select);
+
+ const items = await getDropdownItems(select);
+ const names = [...items].map((item) => item.querySelector('p')?.textContent?.trim());
+ expect(names).toContain(teamProjects[0].name);
+ expect(names).not.toContain(personalProject.name);
+ });
+
+ it('preloads projects only when the caller cannot list them remotely', async () => {
+ projectsStore.globalProjectPermissions = { list: false };
+
+ renderComponent({ props: { modelValue: null } });
+
+ await waitFor(() => {
+ expect(projectsStore.getAvailableProjects).toHaveBeenCalled();
+ });
+ });
+
+ it('does not preload projects when remote search is available', async () => {
+ renderComponent({ props: { modelValue: null } });
+
+ await waitFor(() => {
+ expect(projectsStore.getAvailableProjects).not.toHaveBeenCalled();
+ });
+ });
+
+ // Reported by filter: mounting with a non-null model showed an empty picker while
+ // the caller displayed filtered data. A consumer that restores its filter from the
+ // URL hits this on first paint.
+ it('shows the project a caller arrives with', async () => {
+ const { getByTestId } = renderComponent({
+ props: { modelValue: { id: teamProjects[0].id } },
+ });
+
+ const input = () =>
+ getByTestId('project-sharing-select').querySelector('input') as HTMLInputElement;
+ await waitFor(() => expect(input().value).toBe(teamProjects[0].name));
+ });
+
+ it('does not echo an arriving selection back at the caller', async () => {
+ const { emitted } = renderComponent({
+ props: { modelValue: { id: teamProjects[0].id } },
+ });
+
+ await waitFor(() => expect(projectsStore.getAvailableProjects).not.toHaveBeenCalled());
+ expect(emitted('update:modelValue')).toBeUndefined();
+ });
+
+ it('resolves an arriving selection that only the member preload can see', async () => {
+ // Nothing is seeded up front, so the `immediate` watcher finds no project at
+ // setup and only the preload can supply it. Seeding it beforehand made this
+ // test pass with the post-preload resolve deleted.
+ projectsStore.globalProjectPermissions = { list: false };
+ projectsStore.myProjects = [];
+ projectsStore.availableProjects = [];
+ projectsStore.getAvailableProjects.mockImplementation(async () => {
+ projectsStore.myProjects = teamProjects;
+ });
+
+ const { getByTestId } = renderComponent({
+ props: { modelValue: { id: teamProjects[1].id } },
+ });
+
+ const input = () =>
+ getByTestId('project-sharing-select').querySelector('input') as HTMLInputElement;
+ await waitFor(() => expect(input().value).toBe(teamProjects[1].name));
+ });
+
+ it('clears the shown selection when the caller resets the model to null', async () => {
+ // Driven through a real `v-model` parent, because the clear path is what the
+ // caller writes back: the insights dashboard nulls its ref after a forbidden
+ // project response.
+ const selected = ref(null);
+ const Host = defineComponent({
+ components: { ProjectFilter },
+ setup: () => ({ selected }),
+ template: '',
+ });
+
+ const { getByTestId } = createComponentRenderer(Host)();
+
+ const select = getByTestId('project-sharing-select');
+ await userEvent.click(select);
+ const items = await getDropdownItems(select);
+ const match = [...items].find(
+ (item) => item.querySelector('p')?.textContent?.trim() === teamProjects[0].name,
+ );
+ await userEvent.click(match as Element);
+
+ await waitFor(() => expect(selected.value).toEqual({ id: teamProjects[0].id }));
+
+ const input = () =>
+ getByTestId('project-sharing-select').querySelector('input') as HTMLInputElement;
+ await waitFor(() => expect(input().value).toBe(teamProjects[0].name));
+
+ selected.value = null;
+
+ await waitFor(() => expect(input().value).toBe(''));
+ });
+});
diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectFilter.vue b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectFilter.vue
new file mode 100644
index 00000000000..91d6f178877
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectFilter.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/projects.routes.test.ts b/packages/frontend/editor-ui/src/features/collaboration/projects/projects.routes.test.ts
index b55b28ffd93..647c064aeab 100644
--- a/packages/frontend/editor-ui/src/features/collaboration/projects/projects.routes.test.ts
+++ b/packages/frontend/editor-ui/src/features/collaboration/projects/projects.routes.test.ts
@@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({
execute: vi.fn(),
}));
-vi.mock('@/features/execution/insights', () => ({
+vi.mock('@n8n/frontend-module-insights', () => ({
useInsightsStore: () => ({
isSummaryEnabled: mocks.isSummaryEnabled,
weeklySummary: { execute: mocks.execute },
diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/projects.routes.ts b/packages/frontend/editor-ui/src/features/collaboration/projects/projects.routes.ts
index 74882b63f6d..c1d2a8e691d 100644
--- a/packages/frontend/editor-ui/src/features/collaboration/projects/projects.routes.ts
+++ b/packages/frontend/editor-ui/src/features/collaboration/projects/projects.routes.ts
@@ -15,7 +15,7 @@ const ExecutionsView = async () =>
const ProjectVariables = async () => await import('./views/ProjectVariables.vue');
function refreshInsightsSummary() {
- void import('@/features/execution/insights')
+ void import('@n8n/frontend-module-insights')
.then(({ useInsightsStore }) => {
const insightsStore = useInsightsStore();
if (insightsStore.isSummaryEnabled) {
diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/views/ProjectVariables.vue b/packages/frontend/editor-ui/src/features/collaboration/projects/views/ProjectVariables.vue
index 8d575696648..7fc7c9d362e 100644
--- a/packages/frontend/editor-ui/src/features/collaboration/projects/views/ProjectVariables.vue
+++ b/packages/frontend/editor-ui/src/features/collaboration/projects/views/ProjectVariables.vue
@@ -33,7 +33,7 @@ import {
import { useAsyncState } from '@vueuse/core';
import pickBy from 'lodash/pickBy';
import type { ComponentExposed } from 'vue-component-type-helpers';
-import { InsightsSummary, useInsightsStore } from '@/features/execution/insights';
+import { InsightsSummary, useInsightsStore } from '@n8n/frontend-module-insights';
import { useEnvironmentsStore } from '@/features/settings/environments.ee/environments.store';
import type { EnvironmentVariable } from '@/features/settings/environments.ee/environments.types';
import VariablesUsageBadge from '@/features/settings/environments.ee/components/VariablesUsageBadge.vue';
diff --git a/packages/frontend/editor-ui/src/features/core/dataTable/DataTableView.vue b/packages/frontend/editor-ui/src/features/core/dataTable/DataTableView.vue
index 7e8d0238553..b26815ae401 100644
--- a/packages/frontend/editor-ui/src/features/core/dataTable/DataTableView.vue
+++ b/packages/frontend/editor-ui/src/features/core/dataTable/DataTableView.vue
@@ -1,7 +1,7 @@
@@ -244,16 +215,13 @@ onBeforeMount(async () => {
-
-import { useTelemetry } from '@n8n/composables/useTelemetry';
import { getLocalTimeZone, isToday } from '@internationalized/date';
+import { useTelemetry } from '@n8n/composables/useTelemetry';
import type {
DateRange,
DateValue,
@@ -9,6 +9,7 @@ import type {
} from '@n8n/design-system';
import { N8nButton, N8nDateRangePicker, N8nIcon } from '@n8n/design-system';
import { computed, ref, shallowRef, watch } from 'vue';
+
import { formatDateRange, getAdjustedDateRange } from '../insights.utils';
import InsightsUpgradeModal from './InsightsUpgradeModal.vue';
diff --git a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDateRangeAlert.test.ts b/packages/modules/insights/frontend/src/components/InsightsDateRangeAlert.test.ts
similarity index 94%
rename from packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDateRangeAlert.test.ts
rename to packages/modules/insights/frontend/src/components/InsightsDateRangeAlert.test.ts
index f9a9c9fda86..f84eac4e308 100644
--- a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDateRangeAlert.test.ts
+++ b/packages/modules/insights/frontend/src/components/InsightsDateRangeAlert.test.ts
@@ -1,8 +1,8 @@
import { CalendarDate } from '@internationalized/date';
-import InsightsDateRangeAlert from '@/features/execution/insights/components/InsightsDateRangeAlert.vue';
-import { createComponentRenderer } from '@/__tests__/render';
+import { createComponentRenderer, defaultSettings } from '@n8n/frontend-test-utils';
import { createTestingPinia } from '@pinia/testing';
-import { defaultSettings } from '@n8n/frontend-test-utils';
+
+import InsightsDateRangeAlert from './InsightsDateRangeAlert.vue';
const renderComponent = createComponentRenderer(InsightsDateRangeAlert, {
global: {
diff --git a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDateRangeAlert.vue b/packages/modules/insights/frontend/src/components/InsightsDateRangeAlert.vue
similarity index 100%
rename from packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDateRangeAlert.vue
rename to packages/modules/insights/frontend/src/components/InsightsDateRangeAlert.vue
diff --git a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsPaywall.vue b/packages/modules/insights/frontend/src/components/InsightsPaywall.vue
similarity index 85%
rename from packages/frontend/editor-ui/src/features/execution/insights/components/InsightsPaywall.vue
rename to packages/modules/insights/frontend/src/components/InsightsPaywall.vue
index 3d6b31aec23..3b4dfbaa99a 100644
--- a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsPaywall.vue
+++ b/packages/modules/insights/frontend/src/components/InsightsPaywall.vue
@@ -1,9 +1,9 @@