mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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*"],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
+4
-1
@@ -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];
|
||||
}>();
|
||||
|
||||
+16
-15
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+14
-7
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+4
-1
@@ -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 = {
|
||||
+2
-2
@@ -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 () => {
|
||||
+2
-1
@@ -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,
|
||||
);
|
||||
Reference in New Issue
Block a user