diff --git a/packages/frontend/@n8n/frontend-vite-config/index.ts b/packages/frontend/@n8n/frontend-vite-config/index.ts index 7a666670007..ebc7b357cfd 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 = [ */ export const modulePackages: Array<{ name: string; dir: string; entry?: boolean }> = [ { name: '@n8n/frontend-module-instance-registry', dir: 'modules/instance-registry/frontend' }, + { name: '@n8n/frontend-module-otel', dir: 'modules/otel/frontend' }, ]; // The code below makes the Vite aliases from the two tables. Keep this code in the same file as diff --git a/packages/frontend/editor-ui/eslint.config.mjs b/packages/frontend/editor-ui/eslint.config.mjs index 2c4aab746fa..2c6c4ccb277 100644 --- a/packages/frontend/editor-ui/eslint.config.mjs +++ b/packages/frontend/editor-ui/eslint.config.mjs @@ -2,6 +2,31 @@ import { defineConfig } from 'eslint/config'; import { frontendConfig } from '@n8n/eslint-config/frontend'; import oxlint from 'eslint-plugin-oxlint'; +/** + * Extraction ratchet: a feature that has become a module package must not reappear + * under `src/features/`. Append one entry per extraction — this list only grows. + * + * The old path no longer resolves, so this is about the message, not the failure: it + * names the package and it says that the shell reaches a module through + * `src/app/modules.manifest.ts`, not through a deep path. + * + * Spread into every block that sets `no-restricted-imports`. Flat-config replaces + * rule options rather than merging them, so a scoped block that omits these patterns + * would switch the ratchet off for its own files. + */ +const extractedFeatures = [ + { + group: ['@/features/instanceRegistry', '@/features/instanceRegistry/*'], + message: + 'instanceRegistry is the @n8n/frontend-module-instance-registry package. The shell registers a module through src/app/modules.manifest.ts.', + }, + { + group: ['@/features/settings/otel', '@/features/settings/otel/*'], + message: + 'otel is the @n8n/frontend-module-otel package. The shell registers a module through src/app/modules.manifest.ts.', + }, +]; + export default defineConfig( frontendConfig, { @@ -238,6 +263,7 @@ export default defineConfig( '@typescript-eslint/no-unsafe-argument': 'warn', '@typescript-eslint/no-unsafe-member-access': 'warn', '@typescript-eslint/no-unsafe-return': 'warn', + '@typescript-eslint/no-restricted-imports': ['error', { patterns: extractedFeatures }], }, }, { @@ -299,6 +325,7 @@ export default defineConfig( 'error', { patterns: [ + ...extractedFeatures, { group: ['**/ndv/runData/components/RunData.vue'], message: diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index 7ad520663a6..3e9a701dc3e 100644 --- a/packages/frontend/editor-ui/package.json +++ b/packages/frontend/editor-ui/package.json @@ -50,6 +50,7 @@ "@n8n/design-system": "workspace:*", "@n8n/frontend-constants": "workspace:*", "@n8n/frontend-module-instance-registry": "workspace:*", + "@n8n/frontend-module-otel": "workspace:*", "@n8n/frontend-module-sdk": "workspace:*", "@n8n/frontend-utils": "workspace:*", "@n8n/i18n": "workspace:*", diff --git a/packages/frontend/editor-ui/src/app/modules.manifest.ts b/packages/frontend/editor-ui/src/app/modules.manifest.ts index 23833d25c4e..012924e275a 100644 --- a/packages/frontend/editor-ui/src/app/modules.manifest.ts +++ b/packages/frontend/editor-ui/src/app/modules.manifest.ts @@ -5,9 +5,9 @@ import { MCPModule } from '@/features/ai/mcpAccess/module.descriptor'; import { ChatModule } from '@/features/ai/chatHub/module.descriptor'; import { InstanceAiModule } from '@/features/ai/instanceAi/module.descriptor'; import { AgentsModule } from '@/features/agents/module.descriptor'; -import { OtelModule } from '@/features/settings/otel/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'; /** * Hard-coding modules list until we have a dynamic way to load modules. diff --git a/packages/frontend/editor-ui/src/app/stores/ui.store.settingsPages.test.ts b/packages/frontend/editor-ui/src/app/stores/ui.store.settingsPages.test.ts new file mode 100644 index 00000000000..c8c2c9bc49a --- /dev/null +++ b/packages/frontend/editor-ui/src/app/stores/ui.store.settingsPages.test.ts @@ -0,0 +1,51 @@ +import { OtelModule } from '@n8n/frontend-module-otel'; +import { useRBACStore } from '@n8n/stores/rbac.store'; +import { useSettingsStore } from '@n8n/stores/settings.store'; +import { createPinia, setActivePinia } from 'pinia'; + +import { useUIStore } from '@/app/stores/ui.store'; + +/** + * Guards the shell half of the settings-sidebar gate: `settingsSidebarItems` drops + * the pages of a module the instance has not activated. + * + * Driven with a real module descriptor rather than a fixture, because the gate only + * holds if the descriptor's `id` is the same id `/rest/module-settings` reports. + * The scope half of the old gate lives in the descriptor's `available` getter and is + * covered by `otel.module.test.ts` in the module package. + */ +describe('uiStore.settingsSidebarItems', () => { + const registerOtel = ({ moduleActive }: { moduleActive: boolean }) => { + const settingsStore = useSettingsStore(); + settingsStore.settings = { + ...settingsStore.settings, + activeModules: moduleActive ? [OtelModule.id] : [], + }; + + useRBACStore().setGlobalScopes(['otel:manage']); + + const uiStore = useUIStore(); + uiStore.registerSettingsPages(OtelModule.id, OtelModule.settingsPages ?? []); + + return uiStore; + }; + + const otelItem = (uiStore: ReturnType) => + uiStore.settingsSidebarItems.find((item) => item.id === 'settings-opentelemetry'); + + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('should list the pages of an active module', () => { + const uiStore = registerOtel({ moduleActive: true }); + + expect(otelItem(uiStore)?.available).toBe(true); + }); + + it('should drop the pages of an inactive module, even when the user holds the scope', () => { + const uiStore = registerOtel({ moduleActive: false }); + + expect(otelItem(uiStore)).toBeUndefined(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/settings/otel/module.descriptor.test.ts b/packages/frontend/editor-ui/src/features/settings/otel/module.descriptor.test.ts deleted file mode 100644 index d30445f67b9..00000000000 --- a/packages/frontend/editor-ui/src/features/settings/otel/module.descriptor.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { createPinia, setActivePinia } from 'pinia'; -import { useRBACStore } from '@n8n/stores/rbac.store'; -import { useSettingsStore } from '@n8n/stores/settings.store'; -import type { Scope } from '@n8n/permissions'; - -import { useUIStore } from '@/app/stores/ui.store'; -import { OtelModule } from './module.descriptor'; -import { OTEL_SETTINGS_VIEW } from './otel.constants'; - -/** - * Guards the shell-to-descriptor move of the otel settings sidebar item. - * - * The old gate lived in `useSettingsItems.ts` as - * `isModuleActive('otel') && hasPermission(['rbac'], { rbac: { scope: 'otel:manage' } })`. - * It is now split: `ui.store`'s `settingsSidebarItems` owns the module-active - * half, and the descriptor's `available` getter owns the scope half. These tests - * exercise the real stores so the two halves together still equal the old gate. - */ -describe('OtelModule settings sidebar item', () => { - const registerOtel = ({ - moduleActive, - scopes, - }: { - moduleActive: boolean; - scopes: Scope[]; - }) => { - const settingsStore = useSettingsStore(); - settingsStore.settings = { - ...settingsStore.settings, - activeModules: moduleActive ? ['otel'] : [], - }; - - useRBACStore().setGlobalScopes(scopes); - - const uiStore = useUIStore(); - uiStore.registerSettingsPages(OtelModule.id, OtelModule.settingsPages ?? []); - - return uiStore; - }; - - const otelItem = (uiStore: ReturnType) => - uiStore.settingsSidebarItems.find((item) => item.id === 'settings-opentelemetry'); - - beforeEach(() => { - setActivePinia(createPinia()); - }); - - it('should hide the item from a user without the otel:manage scope', () => { - const uiStore = registerOtel({ moduleActive: true, scopes: [] }); - - expect(otelItem(uiStore)?.available).toBe(false); - }); - - it('should hide the item from a user holding only an unrelated scope', () => { - const uiStore = registerOtel({ moduleActive: true, scopes: ['workflow:read'] }); - - expect(otelItem(uiStore)?.available).toBe(false); - }); - - it('should show the item to a user with the otel:manage scope', () => { - const uiStore = registerOtel({ moduleActive: true, scopes: ['otel:manage'] }); - - expect(otelItem(uiStore)?.available).toBe(true); - }); - - it('should hide the item when the otel module is inactive, even with the scope', () => { - const uiStore = registerOtel({ moduleActive: false, scopes: ['otel:manage'] }); - - expect(otelItem(uiStore)).toBeUndefined(); - }); - - it('should re-evaluate availability when scopes change after registration', () => { - const uiStore = registerOtel({ moduleActive: true, scopes: [] }); - expect(otelItem(uiStore)?.available).toBe(false); - - useRBACStore().addGlobalScope('otel:manage'); - - expect(otelItem(uiStore)?.available).toBe(true); - }); - - it('should keep routing to the unchanged SettingsOpenTelemetryView route name', () => { - const uiStore = registerOtel({ moduleActive: true, scopes: ['otel:manage'] }); - - expect(OTEL_SETTINGS_VIEW).toBe('SettingsOpenTelemetryView'); - expect(otelItem(uiStore)?.route).toEqual({ to: { name: 'SettingsOpenTelemetryView' } }); - expect(OtelModule.routes?.[0]).toMatchObject({ - path: 'opentelemetry', - name: 'SettingsOpenTelemetryView', - }); - }); - - it('should keep the route rbac middleware, which gates direct URL access', () => { - expect(OtelModule.routes?.[0].meta).toMatchObject({ - middleware: ['authenticated', 'rbac', 'custom'], - middlewareOptions: { rbac: { scope: 'otel:manage' } }, - }); - }); -}); diff --git a/packages/frontend/editor-ui/tsconfig.json b/packages/frontend/editor-ui/tsconfig.json index f26492f3b11..b976a921921 100644 --- a/packages/frontend/editor-ui/tsconfig.json +++ b/packages/frontend/editor-ui/tsconfig.json @@ -31,6 +31,8 @@ "@n8n/frontend-module-instance-registry/*": [ "../../modules/instance-registry/frontend/src/*" ], + "@n8n/frontend-module-otel": ["../../modules/otel/frontend/src/index.ts"], + "@n8n/frontend-module-otel/*": ["../../modules/otel/frontend/src/*"], "@n8n/frontend-utils*": ["../@n8n/frontend-utils/src*"], "@n8n/frontend-constants*": ["../@n8n/frontend-constants/src*"], "@n8n/chat*": ["../@n8n/chat/src*"], diff --git a/packages/modules/otel/frontend/README.md b/packages/modules/otel/frontend/README.md new file mode 100644 index 00000000000..6ee2036f8d4 --- /dev/null +++ b/packages/modules/otel/frontend/README.md @@ -0,0 +1,42 @@ +# @n8n/frontend-module-otel + +Frontend feature module for the OpenTelemetry settings page. Consumed from source +by the editor-ui shell through `src/app/modules.manifest.ts`; there is no build +step and no `dist`. + +```bash +pnpm turbo typecheck --filter=@n8n/frontend-module-otel +pnpm turbo lint --filter=@n8n/frontend-module-otel +pnpm turbo test --filter=@n8n/frontend-module-otel +``` + +Go through turbo, not `pnpm --filter typecheck`: this package is consumed +from source, and on a cold tree its platform dependencies have not been built +yet. Turbo builds them first; the bare pnpm form does not. + +## What this module contributes + +This is the first extracted module with a UI surface. Its descriptor declares a +lazy route (`SettingsOpenTelemetryView`) and a `settingsPages` entry. The shell +gates both on `isModuleActive('otel')`; the sidebar item additionally gates on +the `otel:manage` scope through the descriptor's `available` getter. + +The route name is owned here (`OTEL_SETTINGS_VIEW` in `otel.constants.ts`), not +by the shared `VIEWS` enum. `assertUniqueRouteNames` in `@n8n/frontend-module-sdk` +keeps the names collision-free. + +Strings still live in the central `@n8n/i18n` `en.json` under +`settings.opentelemetry.*`. Per-module locales are a later wave. + +## Import rules + +- Depend on foundation and platform packages only (`@n8n/design-system`, + `@n8n/stores`, `@n8n/composables`, `@n8n/i18n`, `@n8n/rest-api-client`, + `@n8n/frontend-module-sdk`). Never import another `@n8n/frontend-module-*`, + and never import `@/…` from the shell. +- `@n8n/stores` and `@n8n/composables` are **subpath-only** — import + `@n8n/stores/settings.store`, not `@n8n/stores`. +- The no-cross-module rule is currently a convention: the shared tsconfig base + omits sibling modules from `paths`, which blocks an accidental import but not + a deliberate one (declaring the dependency makes it typecheck clean). The + ESLint rule that actually enforces it is CAT-3692. diff --git a/packages/modules/otel/frontend/biome.jsonc b/packages/modules/otel/frontend/biome.jsonc new file mode 100644 index 00000000000..f882da95a58 --- /dev/null +++ b/packages/modules/otel/frontend/biome.jsonc @@ -0,0 +1,4 @@ +{ + "$schema": "../../../../node_modules/@biomejs/biome/configuration_schema.json", + "extends": ["../../../../biome.jsonc"] +} diff --git a/packages/modules/otel/frontend/eslint.config.mjs b/packages/modules/otel/frontend/eslint.config.mjs new file mode 100644 index 00000000000..3709bc37c42 --- /dev/null +++ b/packages/modules/otel/frontend/eslint.config.mjs @@ -0,0 +1,4 @@ +import { defineConfig } from 'eslint/config'; +import { frontendConfig } from '@n8n/eslint-config/frontend'; + +export default defineConfig(frontendConfig); diff --git a/packages/modules/otel/frontend/package.json b/packages/modules/otel/frontend/package.json new file mode 100644 index 00000000000..17a243fd7dd --- /dev/null +++ b/packages/modules/otel/frontend/package.json @@ -0,0 +1,57 @@ +{ + "name": "@n8n/frontend-module-otel", + "version": "0.1.0", + "type": "module", + "main": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "clean": "rimraf .turbo", + "typecheck": "vue-tsc --noEmit", + "test": "vitest run", + "test:changed": "janitor test-scoped", + "test:dev": "vitest", + "lint": "eslint src --quiet", + "lint:fix": "eslint src --fix", + "lint:styles": "stylelint \"src/**/*.{scss,sass,vue}\" --cache", + "lint:styles:fix": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache", + "format": "biome format --write . && prettier --write . --ignore-path ../../../../.prettierignore", + "format:check": "biome ci . && prettier --check . --ignore-path ../../../../.prettierignore" + }, + "dependencies": { + "@n8n/composables": "workspace:*", + "@n8n/design-system": "workspace:*", + "@n8n/frontend-module-sdk": "workspace:*", + "@n8n/i18n": "workspace:*", + "@n8n/rest-api-client": "workspace:*", + "@n8n/stores": "workspace:*", + "pinia": "catalog:frontend", + "vue": "catalog:frontend", + "vue-router": "catalog:frontend" + }, + "devDependencies": { + "@iconify/json": "catalog:", + "@n8n/eslint-config": "workspace:*", + "@n8n/frontend-vite-config": "workspace:*", + "@n8n/permissions": "workspace:*", + "@n8n/playwright-janitor": "workspace:*", + "@n8n/stylelint-config": "workspace:*", + "@n8n/typescript-config": "workspace:*", + "@n8n/vitest-config": "workspace:*", + "@pinia/testing": "^0.1.6", + "@testing-library/jest-dom": "catalog:frontend", + "@testing-library/user-event": "catalog:frontend", + "@testing-library/vue": "catalog:frontend", + "@vitejs/plugin-vue": "catalog:frontend", + "eslint": "catalog:", + "stylelint": "catalog:", + "typescript": "catalog:", + "unplugin-icons": "catalog:frontend", + "vite": "catalog:", + "vite-svg-loader": "catalog:frontend", + "vitest": "catalog:", + "vue-tsc": "catalog:frontend" + }, + "license": "LicenseRef-n8n-sustainable-use" +} diff --git a/packages/frontend/editor-ui/src/features/settings/otel/OtelSettingsRow.vue b/packages/modules/otel/frontend/src/OtelSettingsRow.vue similarity index 100% rename from packages/frontend/editor-ui/src/features/settings/otel/OtelSettingsRow.vue rename to packages/modules/otel/frontend/src/OtelSettingsRow.vue diff --git a/packages/frontend/editor-ui/src/features/settings/otel/OtelStatusControl.vue b/packages/modules/otel/frontend/src/OtelStatusControl.vue similarity index 94% rename from packages/frontend/editor-ui/src/features/settings/otel/OtelStatusControl.vue rename to packages/modules/otel/frontend/src/OtelStatusControl.vue index 837cb79ff9d..be1c34e3080 100644 --- a/packages/frontend/editor-ui/src/features/settings/otel/OtelStatusControl.vue +++ b/packages/modules/otel/frontend/src/OtelStatusControl.vue @@ -1,7 +1,8 @@