refactor(editor): Extract otel into a frontend module package (no-changelog) (#36679)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: alexgrozav <6179477+alexgrozav@users.noreply.github.com>
Co-authored-by: CharlieKolb <13814565+CharlieKolb@users.noreply.github.com>
This commit is contained in:
Alex Grozav
2026-08-27 15:34:47 +00:00
committed by GitHub
parent 6eb19e1668
commit e2afe16634
32 changed files with 702 additions and 256 deletions
@@ -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
@@ -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:
+1
View File
@@ -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:*",
@@ -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.
@@ -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<typeof useUIStore>) =>
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();
});
});
@@ -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<typeof useUIStore>) =>
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' } },
});
});
});
@@ -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*"],
+42
View File
@@ -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 <pkg> 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.
@@ -0,0 +1,4 @@
{
"$schema": "../../../../node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["../../../../biome.jsonc"]
}
@@ -0,0 +1,4 @@
import { defineConfig } from 'eslint/config';
import { frontendConfig } from '@n8n/eslint-config/frontend';
export default defineConfig(frontendConfig);
@@ -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"
}
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, useCssModule } from 'vue';
import { N8nButton, N8nDropdownMenu, N8nIcon } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed, useCssModule } from 'vue';
import OtelStatusDot from './OtelStatusDot.vue';
/*
@@ -22,6 +23,8 @@ withDefaults(
);
const emit = defineEmits<{
// Vue's `v-model:enabled` contract fixes this name, and no naming format allows a colon.
// eslint-disable-next-line @typescript-eslint/naming-convention
'update:enabled': [enabled: boolean];
}>();
@@ -1,10 +1,11 @@
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import { waitFor } from '@testing-library/vue';
import { createComponentRenderer } from './__tests__/render';
import type { OtelSettingsResponse } from './otel.api';
import { useOtelStore } from './otel.store';
import SettingsOpenTelemetryView from './SettingsOpenTelemetryView.vue';
import type { OtelSettingsResponse } from './otel.api';
const showMessage = vi.fn();
const showError = vi.fn();
@@ -17,10 +18,6 @@ vi.mock('@n8n/composables/useTelemetry', () => ({
useTelemetry: () => ({ track: telemetryTrack }),
}));
vi.mock('@/app/composables/useDocumentTitle', () => ({
useDocumentTitle: () => ({ set: vi.fn() }),
}));
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ restApiContext: { baseUrl: '', pushRef: '' } }),
}));
@@ -38,14 +35,18 @@ vi.mock('vue-router', async (importOriginal) => {
};
});
const getOtelSettingsMock = vi.fn();
const updateOtelSettingsMock = vi.fn();
const sendOtelTestTraceMock = vi.fn();
// Typed, so the factory below returns `Promise<unknown>` rather than the `any` a bare
// `vi.fn()` yields — the module package lints `no-unsafe-return` at error level.
type ApiMock = (...args: unknown[]) => Promise<unknown>;
const getOtelSettingsMock = vi.fn<ApiMock>();
const updateOtelSettingsMock = vi.fn<ApiMock>();
const sendOtelTestTraceMock = vi.fn<ApiMock>();
vi.mock('./otel.api', () => ({
getOtelSettings: (...args: unknown[]) => getOtelSettingsMock(...args),
updateOtelSettings: (...args: unknown[]) => updateOtelSettingsMock(...args),
sendOtelTestTrace: (...args: unknown[]) => sendOtelTestTraceMock(...args),
getOtelSettings: async (...args: unknown[]) => await getOtelSettingsMock(...args),
updateOtelSettings: async (...args: unknown[]) => await updateOtelSettingsMock(...args),
sendOtelTestTrace: async (...args: unknown[]) => await sendOtelTestTraceMock(...args),
}));
const makeSettings = (overrides: Partial<OtelSettingsResponse> = {}): OtelSettingsResponse => ({
@@ -198,7 +199,7 @@ describe('SettingsOpenTelemetryView', () => {
await waitFor(() => {
expect(showError).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
expect(store.settings!.enabled).toBe(false);
expect(store.settings.enabled).toBe(false);
});
});
@@ -375,7 +376,7 @@ describe('SettingsOpenTelemetryView', () => {
await userEvent.type(keyInput, 'x-api-key');
await waitFor(() => {
expect(store.settings!.exporterHeaders).toContain('x-api-key');
expect(store.settings.exporterHeaders).toContain('x-api-key');
});
});
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue';
import { onBeforeRouteLeave, type NavigationGuardNext } from 'vue-router';
import { useDocumentTitle } from '@n8n/composables/useDocumentTitle';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useToast } from '@n8n/composables/useToast';
import {
N8nButton,
N8nCheckbox,
@@ -17,11 +18,12 @@ import {
N8nSettingsSection,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useToast } from '@n8n/composables/useToast';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useOtelStore, headersStringToPairs, headersPairsToString } from './otel.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { computed, ref, watch, onMounted } from 'vue';
import { onBeforeRouteLeave, type NavigationGuardNext } from 'vue-router';
import { OTEL_FIELD_ENV_VARS, OTEL_TEST_SPAN_NAME } from './otel.constants';
import { useOtelStore, headersStringToPairs, headersPairsToString } from './otel.store';
import { createSampleRateFormat } from './otel.utils';
import OtelSettingsRow from './OtelSettingsRow.vue';
import OtelStatusControl from './OtelStatusControl.vue';
@@ -31,7 +33,12 @@ const OTEL_DOCS_URL = 'https://docs.n8n.io/hosting/logging-monitoring/openteleme
const i18n = useI18n();
const telemetry = useTelemetry();
const toast = useToast();
const documentTitle = useDocumentTitle();
// The shell's wrapper adds a claim guard for `setDocumentTitle`, which only the
// canvas calls. This view calls `set`, so it uses the platform composable directly
// and passes the release channel the wrapper would have supplied.
const documentTitle = useDocumentTitle({
releaseChannel: useSettingsStore().settings.releaseChannel,
});
const otelStore = useOtelStore();
const headerPairs = ref<Array<{ key: string; value: string }>>([]);
@@ -0,0 +1,36 @@
import { N8nPlugin } from '@n8n/design-system';
import { i18nInstance } from '@n8n/i18n';
import { render, type RenderOptions as TestingLibraryRenderOptions } from '@testing-library/vue';
import type { Pinia } from 'pinia';
import { PiniaVuePlugin } from 'pinia';
/**
* The shell's `@/__tests__/render` cannot come with the module: it provides the
* workflow-document store and installs shell-only plugins. This module renders one
* settings view built from design-system components, so it needs the design-system
* directives, i18n and a pinia — nothing else.
*/
export type RenderOptions<T> = Omit<TestingLibraryRenderOptions<T>, 'props'> & {
pinia?: Pinia;
props?: Partial<TestingLibraryRenderOptions<T>['props']>;
};
export function createComponentRenderer<T>(component: T, defaultOptions: RenderOptions<T> = {}) {
return (options: RenderOptions<T> = {}) => {
const { pinia, ...renderOptions } = { ...defaultOptions, ...options };
return render(component, {
...renderOptions,
global: {
...renderOptions.global,
plugins: [
i18nInstance,
PiniaVuePlugin,
N8nPlugin,
...(renderOptions.global?.plugins ?? []),
...(pinia ? [pinia] : []),
],
},
} as TestingLibraryRenderOptions<T>);
};
}
@@ -0,0 +1,21 @@
// The shared jsdom harness — observers, matchMedia, canvas, timers, teardown guards.
import '@n8n/vitest-config/setup/frontend';
import { loadLanguage, type LocaleMessages } from '@n8n/i18n';
import englishBaseText from '@n8n/i18n/locales/en.json';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach } from 'vitest';
// Framework boot stays per-package on purpose: `@n8n/i18n` devDepends on
// `@n8n/vitest-config`, so booting i18n from inside the shared harness would
// close a turbo build cycle.
//
// `useI18n()` reads a module-level singleton, so this runs once at import and
// needs no app instance — but `baseText` returns the key itself until the
// messages are loaded, and this module's strings still live in the central
// `en.json` (per-module locales are a later wave).
loadLanguage('en', englishBaseText as unknown as LocaleMessages);
beforeEach(() => {
setActivePinia(createPinia());
});
@@ -0,0 +1,4 @@
// The module's only public entry. The shell imports the descriptor from here via
// `modules.manifest.ts`; anything else the shell (or a test) needs must be exported
// here too — deep paths into `src/` are not part of the contract.
export { OtelModule } from './otel.module';
@@ -1,5 +1,6 @@
import type { IRestApiContext } from '@n8n/rest-api-client';
import { makeRestApiRequest } from '@n8n/rest-api-client';
import { getOtelSettings, updateOtelSettings, sendOtelTestTrace } from './otel.api';
import type { OtelSettingsResponse, OtelTestConnection } from './otel.api';
@@ -0,0 +1,75 @@
import type { Scope } from '@n8n/permissions';
import { useRBACStore } from '@n8n/stores/rbac.store';
import { createPinia, setActivePinia } from 'pinia';
import { OTEL_SETTINGS_VIEW } from './otel.constants';
import { OtelModule } from './otel.module';
/**
* Guards the descriptor half of the shell-to-descriptor move of the otel settings
* sidebar item.
*
* The old gate lived in the shell's `useSettingsItems.ts` as
* `isModuleActive('otel') && hasPermission(['rbac'], { rbac: { scope: 'otel:manage' } })`.
* It is now split: the descriptor's `available` getter owns the scope half, which is
* what this file covers, and `ui.store`'s `settingsSidebarItems` owns the
* module-active half, covered by `ui.store.settingsPages.test.ts` in the shell.
*/
describe('OtelModule', () => {
const settingsPage = () =>
OtelModule.settingsPages?.find((item) => item.id === 'settings-opentelemetry');
const withScopes = (scopes: Scope[]) => {
useRBACStore().setGlobalScopes(scopes);
return settingsPage();
};
beforeEach(() => {
setActivePinia(createPinia());
});
describe('settings sidebar item', () => {
it('should hide the item from a user without the otel:manage scope', () => {
expect(withScopes([])?.available).toBe(false);
});
it('should hide the item from a user holding only an unrelated scope', () => {
expect(withScopes(['workflow:read'])?.available).toBe(false);
});
it('should show the item to a user with the otel:manage scope', () => {
expect(withScopes(['otel:manage'])?.available).toBe(true);
});
it('should re-evaluate availability when scopes change after registration', () => {
const item = withScopes([]);
expect(item?.available).toBe(false);
useRBACStore().addGlobalScope('otel:manage');
expect(item?.available).toBe(true);
});
});
describe('route', () => {
it('should keep routing to the unchanged SettingsOpenTelemetryView route name', () => {
expect(OTEL_SETTINGS_VIEW).toBe('SettingsOpenTelemetryView');
expect(settingsPage()?.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' } },
});
});
it('should load the view lazily, so the shell does not pull it in at boot', () => {
expect(typeof OtelModule.routes?.[0].component).toBe('function');
});
});
});
@@ -1,11 +1,14 @@
import { useI18n } from '@n8n/i18n';
import type { FrontendModuleDescription } from '@n8n/frontend-module-sdk';
import { useI18n } from '@n8n/i18n';
import { useRBACStore } from '@n8n/stores/rbac.store';
import { OTEL_SETTINGS_VIEW } from './otel.constants';
const i18n = useI18n();
// typescript-eslint reads an SFC import as `any`, because only vue-tsc can type one.
// `pnpm turbo typecheck` is what checks this component for real.
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
const SettingsOpenTelemetryView = async () => await import('./SettingsOpenTelemetryView.vue');
export const OtelModule: FrontendModuleDescription = {
@@ -1,8 +1,8 @@
import { createPinia, setActivePinia } from 'pinia';
import * as otelApi from './otel.api';
import { useOtelStore, headersStringToPairs, headersPairsToString } from './otel.store';
import type { OtelSettingsResponse } from './otel.api';
import { useOtelStore, headersStringToPairs, headersPairsToString } from './otel.store';
vi.mock('./otel.api', () => ({
getOtelSettings: vi.fn(),
@@ -138,7 +138,7 @@ describe('useOtelStore', () => {
store.settings.exporterEndpoint = 'https://changed.io';
expect(store.savedSettings!.exporterEndpoint).toBe('https://original.io');
expect(store.savedSettings.exporterEndpoint).toBe('https://original.io');
});
it('sets loading to true during the call and resets it after', async () => {
@@ -1,6 +1,7 @@
import { useRootStore } from '@n8n/stores/useRootStore';
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import { getOtelSettings, updateOtelSettings, sendOtelTestTrace } from './otel.api';
import type { OtelSettings, OtelSettingsResponse } from './otel.api';
import { OTEL_STORE } from './otel.constants';
@@ -0,0 +1,9 @@
import { baseConfig } from '@n8n/stylelint-config/base';
export default {
...baseConfig,
rules: {
...baseConfig.rules,
'@n8n/css-var-naming': [true, { severity: 'error' }],
},
};
@@ -0,0 +1,21 @@
{
"extends": "@n8n/typescript-config/tsconfig.frontend-module.json",
"compilerOptions": {
// `rootDirs`, `types` and `include` cannot be inherited from the base: relative entries in
// them resolve against this file, so a copy in the base would point at the base's directory.
// (`paths` is the exception and does come from the base.)
"rootDirs": [".", "../../../frontend/@n8n/design-system/src"],
// The two `.d.ts` entries are ambient declarations this package never imports, so nothing
// pulls them into the program: `~icons/*` and `markdown-it-task-lists` for design-system's
// source, `window.BASE_PATH` for `@n8n/stores`'s. Consuming those packages from source is
// what makes them the consumer's problem — a built `dist` would have carried them.
"types": [
"vite/client",
"vitest/globals",
"unplugin-icons/types/vue",
"../../../frontend/@n8n/design-system/src/shims-modules.d.ts",
"../../../frontend/@n8n/stores/src/shims.d.ts"
]
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}
@@ -0,0 +1,51 @@
import vue from '@vitejs/plugin-vue';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import icons from 'unplugin-icons/vite';
import svgLoader from 'vite-svg-loader';
import { defineConfig, mergeConfig } from 'vite';
import { frontendAliases } from '@n8n/frontend-vite-config';
import { vitestConfig } from '@n8n/vitest-config/frontend';
const packageDir = fileURLToPath(new URL('.', import.meta.url));
const packagesDir = resolve(packageDir, '..', '..', '..');
export default mergeConfig(
defineConfig({
// `@n8n/design-system`'s icon set reaches for two things Vite does not handle on its
// own: `~icons/lucide/*` virtual modules and `./custom/*.svg` single-file components.
// Consuming design-system from source makes both the consumer's problem — without
// `svgLoader` an `.svg` import returns a data-URI string, which Vue then renders as a
// tag name and jsdom rejects. Every module that renders a design-system component
// needs these two plugins.
plugins: [
vue(),
// Off, so a test never reaches the network for a missing collection.
icons({ compiler: 'vue3', autoInstall: false }),
svgLoader({
svgoConfig: {
plugins: [
{
name: 'preset-default',
params: {
overrides: {
// The icons rely on their ids, and on a viewBox to stay scalable.
cleanupIds: false,
removeViewBox: false,
},
},
},
],
},
}),
],
resolve: {
// The same platform mapping the editor-ui dev server uses, so a test resolves
// `@n8n/stores/...` from source rather than from a stale `dist` — the two disagreeing is
// what put 1,111 specifiers on the wrong side of the src/dist line. Sibling modules are
// deliberately absent: nothing here should make a cross-module import resolve.
alias: frontendAliases(packagesDir),
},
}),
vitestConfig,
);