mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat: Add instance triggers settings view with live trigger activity
Adds an owner/admin settings page listing every trigger on the instance (webhooks, schedules, pollers and sub-workflow triggers), split into active and inactive sections with node type filtering, project badges, caller pills, and live flash + call counting via push events. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
dfa841a040
commit
839730bba7
|
|
@ -14,4 +14,27 @@ export type TestWebhookReceived = {
|
|||
};
|
||||
};
|
||||
|
||||
export type WebhookPushMessage = TestWebhookDeleted | TestWebhookReceived;
|
||||
export type WebhookReceived = {
|
||||
type: 'webhookReceived';
|
||||
data: {
|
||||
workflowId: string;
|
||||
node: string;
|
||||
method: string;
|
||||
path: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TriggerFired = {
|
||||
type: 'triggerFired';
|
||||
data: {
|
||||
workflowId: string;
|
||||
/** `trigger` for schedules/pollers, `integrated` for sub-workflow calls */
|
||||
mode: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type WebhookPushMessage =
|
||||
| TestWebhookDeleted
|
||||
| TestWebhookReceived
|
||||
| WebhookReceived
|
||||
| TriggerFired;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { DatabaseConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
import { DataSource, EntityManager, IsNull, LessThan, Repository, Not } from '@n8n/typeorm';
|
||||
import { DataSource, EntityManager, In, IsNull, LessThan, Repository, Not } from '@n8n/typeorm';
|
||||
|
||||
import { WorkflowDependency } from '../entities';
|
||||
|
||||
|
|
@ -159,4 +159,17 @@ export class WorkflowDependencyRepository extends Repository<WorkflowDependency>
|
|||
const { tablePrefix } = this.databaseConfig;
|
||||
return this.manager.connection.driver.escape(`${tablePrefix}${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the caller → callee edges of workflows that reference any of the
|
||||
* given workflows via an Execute Workflow node. `workflowId` is the caller,
|
||||
* `dependencyKey` the called workflow's id.
|
||||
*/
|
||||
async findCallersOfWorkflows(workflowIds: string[]) {
|
||||
if (workflowIds.length === 0) return [];
|
||||
return await this.find({
|
||||
select: ['workflowId', 'dependencyKey'],
|
||||
where: { dependencyType: 'workflowCall', dependencyKey: In(workflowIds) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -282,6 +282,29 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve id, name and the published version (with its nodes) of every
|
||||
* unarchived workflow that has an active version.
|
||||
*/
|
||||
async findPublishedWithActiveVersionNodes() {
|
||||
return await this.find({
|
||||
select: ['id', 'name'],
|
||||
where: { activeVersionId: Not(IsNull()), isArchived: false },
|
||||
relations: { activeVersion: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve id, name, nodes and connections of every unarchived workflow
|
||||
* without a published (active) version.
|
||||
*/
|
||||
async findUnpublishedWithNodes() {
|
||||
return await this.find({
|
||||
select: ['id', 'name', 'nodes', 'connections'],
|
||||
where: { activeVersionId: IsNull(), isArchived: false },
|
||||
});
|
||||
}
|
||||
|
||||
async findPreExistingWorkflows(workflowIds: string[]): Promise<WorkflowEntity[]> {
|
||||
if (workflowIds.length === 0) {
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ exports[`Scope Information > ensure scopes are defined correctly 1`] = `
|
|||
"projectVariable:update",
|
||||
"projectVariable:delete",
|
||||
"projectVariable:list",
|
||||
"webhook:list",
|
||||
"workersView:manage",
|
||||
"workflow:share",
|
||||
"workflow:unshare",
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export const RESOURCES = {
|
|||
] as const,
|
||||
variable: [...DEFAULT_OPERATIONS] as const,
|
||||
projectVariable: [...DEFAULT_OPERATIONS] as const,
|
||||
webhook: ['list'] as const,
|
||||
workersView: ['manage'] as const,
|
||||
workflow: [
|
||||
'share',
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
|||
'workflow:move',
|
||||
'workflow:enableRedaction',
|
||||
'workflow:disableRedaction',
|
||||
'webhook:list',
|
||||
'workersView:manage',
|
||||
'project:list',
|
||||
'project:create',
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ describe('permissions', () => {
|
|||
user: {},
|
||||
variable: {},
|
||||
projectVariable: {},
|
||||
webhook: {},
|
||||
workersView: {},
|
||||
workflow: {},
|
||||
workflowTags: {},
|
||||
|
|
@ -146,6 +147,7 @@ describe('permissions', () => {
|
|||
list: true,
|
||||
read: true,
|
||||
},
|
||||
webhook: {},
|
||||
workersView: {},
|
||||
workflow: {
|
||||
create: true,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { getDataDeduplicationService } from '@/deduplication';
|
|||
import { TestRunCleanupService } from '@/evaluation.ee/test-runner/test-run-cleanup.service.ee';
|
||||
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
|
||||
import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay';
|
||||
import { TriggerActivityEventRelay } from '@/events/relays/trigger-activity.event-relay';
|
||||
import { WorkflowFailureNotificationEventRelay } from '@/events/relays/workflow-failure-notification.event-relay';
|
||||
import { ExecutionDataJsonStore } from '@/executions/execution-data/execution-data-json-store';
|
||||
import { ExpressionObservabilityProvider } from '@/expression-observability/expression-observability.provider';
|
||||
|
|
@ -225,6 +226,7 @@ export abstract class BaseCommand<F = never> {
|
|||
await Container.get(PostHogClient).init();
|
||||
await Container.get(TelemetryEventRelay).init();
|
||||
Container.get(WorkflowFailureNotificationEventRelay).init();
|
||||
Container.get(TriggerActivityEventRelay).init();
|
||||
|
||||
const { engine, poolSize, maxCodeCacheSize, bridgeTimeout, bridgeMemoryLimit, idleTimeout } =
|
||||
this.globalConfig.expressionEngine;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import type { IWorkflowBase } from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { TriggerActivityEventRelay } from '@/events/relays/trigger-activity.event-relay';
|
||||
import type { Push } from '@/push';
|
||||
|
||||
describe('TriggerActivityEventRelay', () => {
|
||||
const eventService = new EventService();
|
||||
const push = mock<Push>();
|
||||
new TriggerActivityEventRelay(eventService, push).init();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should broadcast triggerFired for trigger-mode executions', () => {
|
||||
eventService.emit('workflow-pre-execute', {
|
||||
executionId: 'e1',
|
||||
data: { id: 'wf1' } as IWorkflowBase,
|
||||
mode: 'trigger',
|
||||
});
|
||||
|
||||
expect(push.broadcast).toHaveBeenCalledWith({
|
||||
type: 'triggerFired',
|
||||
data: { workflowId: 'wf1', mode: 'trigger' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should broadcast triggerFired for sub-workflow executions', () => {
|
||||
eventService.emit('workflow-pre-execute', {
|
||||
executionId: 'e3',
|
||||
data: { id: 'wf2' } as IWorkflowBase,
|
||||
mode: 'integrated',
|
||||
});
|
||||
|
||||
expect(push.broadcast).toHaveBeenCalledWith({
|
||||
type: 'triggerFired',
|
||||
data: { workflowId: 'wf2', mode: 'integrated' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore executions of other modes', () => {
|
||||
eventService.emit('workflow-pre-execute', {
|
||||
executionId: 'e2',
|
||||
data: { id: 'wf1' } as IWorkflowBase,
|
||||
mode: 'webhook',
|
||||
});
|
||||
|
||||
expect(push.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { Service } from '@n8n/di';
|
||||
|
||||
import { EventService } from '@/events/event.service';
|
||||
import type { RelayEventMap } from '@/events/maps/relay.event-map';
|
||||
import { EventRelay } from '@/events/relays/event-relay';
|
||||
import { Push } from '@/push';
|
||||
|
||||
/**
|
||||
* Broadcasts in-memory trigger activity (schedules, pollers) so the triggers
|
||||
* settings view can highlight a workflow's triggers when they fire. Webhook
|
||||
* activity is broadcast with node-level detail from `LiveWebhooks` instead.
|
||||
*/
|
||||
@Service()
|
||||
export class TriggerActivityEventRelay extends EventRelay {
|
||||
constructor(
|
||||
eventService: EventService,
|
||||
private readonly push: Push,
|
||||
) {
|
||||
super(eventService);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupListeners({
|
||||
'workflow-pre-execute': (event) => this.onPreExecute(event),
|
||||
});
|
||||
}
|
||||
|
||||
private onPreExecute({ data, mode }: RelayEventMap['workflow-pre-execute']) {
|
||||
// `trigger` = schedules/pollers, `integrated` = sub-workflow calls
|
||||
if (mode !== 'trigger' && mode !== 'integrated') return;
|
||||
|
||||
const workflowId = 'workflowData' in data ? data.workflowData.id : data.id;
|
||||
if (!workflowId) return;
|
||||
|
||||
this.push.broadcast({ type: 'triggerFired', data: { workflowId, mode } });
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ import { mock } from 'vitest-mock-extended';
|
|||
|
||||
import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error';
|
||||
import type { NodeTypes } from '@/node-types';
|
||||
import type { Push } from '@/push';
|
||||
import { LiveWebhooks } from '@/webhooks/live-webhooks';
|
||||
import * as WebhookHelpers from '@/webhooks/webhook-helpers';
|
||||
import type { WebhookService } from '@/webhooks/webhook.service';
|
||||
|
|
@ -52,6 +53,7 @@ describe('LiveWebhooks', () => {
|
|||
const expressionEngineConfig = mock<ExpressionEngineConfig>({
|
||||
allowWebhookIsolateSkip: true,
|
||||
});
|
||||
const push = mock<Push>();
|
||||
|
||||
let liveWebhooks: LiveWebhooks;
|
||||
|
||||
|
|
@ -68,6 +70,7 @@ describe('LiveWebhooks', () => {
|
|||
workflowsConfig,
|
||||
workflowPublishedDataService,
|
||||
expressionEngineConfig,
|
||||
push,
|
||||
);
|
||||
|
||||
// Mock WorkflowExecuteAdditionalData.getBase to avoid DI issues
|
||||
|
|
@ -199,6 +202,16 @@ describe('LiveWebhooks', () => {
|
|||
expect(capturedNodes[0].id).toBe('webhook-node-active');
|
||||
// The allowed-methods lookup is reserved for 404 responses
|
||||
expect(webhookService.getWebhookMethods).not.toHaveBeenCalled();
|
||||
|
||||
expect(push.broadcast).toHaveBeenCalledWith({
|
||||
type: 'webhookReceived',
|
||||
data: {
|
||||
workflowId: WORKFLOW_ID,
|
||||
node: NODE_NAME,
|
||||
method: httpMethod,
|
||||
path: WEBHOOK_PATH,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should look up allowed methods and include them in the 404 when no webhook is registered', async () => {
|
||||
|
|
|
|||
261
packages/cli/src/webhooks/__tests__/webhooks.controller.test.ts
Normal file
261
packages/cli/src/webhooks/__tests__/webhooks.controller.test.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { WebhookEntity } from '@n8n/db';
|
||||
import type {
|
||||
Project,
|
||||
SharedWorkflowRepository,
|
||||
WorkflowDependencyRepository,
|
||||
WorkflowEntity,
|
||||
WorkflowRepository,
|
||||
} from '@n8n/db';
|
||||
import type { IWebhookData } from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { NodeTypes } from '@/node-types';
|
||||
import { getWorkflowWebhooks } from '@/webhooks/webhook-helpers';
|
||||
import type { WebhookService } from '@/webhooks/webhook.service';
|
||||
import { WebhooksController } from '@/webhooks/webhooks.controller';
|
||||
import { getTriggerKinds } from '@/workflows/triggers/trigger-kinds';
|
||||
|
||||
vi.mock('@/workflow-execute-additional-data', () => ({
|
||||
getBase: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
vi.mock('@/webhooks/webhook-helpers', () => ({
|
||||
getWorkflowWebhooks: vi.fn().mockReturnValue([]),
|
||||
}));
|
||||
vi.mock('@/workflows/triggers/trigger-kinds', () => ({
|
||||
getTriggerKinds: vi.fn().mockReturnValue(new Map()),
|
||||
}));
|
||||
|
||||
describe('WebhooksController', () => {
|
||||
const webhookService = mock<WebhookService>();
|
||||
const workflowRepository = mock<WorkflowRepository>();
|
||||
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
|
||||
const workflowDependencyRepository = mock<WorkflowDependencyRepository>();
|
||||
const nodeTypes = mock<NodeTypes>();
|
||||
const controller = new WebhooksController(
|
||||
webhookService,
|
||||
workflowRepository,
|
||||
sharedWorkflowRepository,
|
||||
workflowDependencyRepository,
|
||||
nodeTypes,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
webhookService.findAll.mockResolvedValue([]);
|
||||
workflowRepository.findByIds.mockResolvedValue([]);
|
||||
workflowRepository.findPublishedWithActiveVersionNodes.mockResolvedValue([]);
|
||||
workflowRepository.findUnpublishedWithNodes.mockResolvedValue([]);
|
||||
sharedWorkflowRepository.findOwnerProjectsByWorkflowIds.mockResolvedValue(new Map());
|
||||
workflowDependencyRepository.findCallersOfWorkflows.mockResolvedValue([]);
|
||||
vi.mocked(getTriggerKinds).mockReturnValue(new Map());
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should list registered webhooks with workflow names and full dynamic paths', async () => {
|
||||
const staticWebhook = Object.assign(new WebhookEntity(), {
|
||||
workflowId: 'wf1',
|
||||
webhookPath: 'my-hook',
|
||||
method: 'POST',
|
||||
node: 'Webhook',
|
||||
});
|
||||
const dynamicWebhook = Object.assign(new WebhookEntity(), {
|
||||
workflowId: 'wf2',
|
||||
webhookPath: 'user/:id',
|
||||
webhookId: 'abc123',
|
||||
method: 'GET',
|
||||
node: 'Webhook 2',
|
||||
});
|
||||
webhookService.findAll.mockResolvedValue([staticWebhook, dynamicWebhook]);
|
||||
workflowRepository.findByIds.mockResolvedValue([
|
||||
{
|
||||
id: 'wf1',
|
||||
name: 'My Workflow',
|
||||
nodes: [{ name: 'Webhook', type: 'n8n-nodes-base.webhook' }],
|
||||
} as WorkflowEntity,
|
||||
]);
|
||||
sharedWorkflowRepository.findOwnerProjectsByWorkflowIds.mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'wf1',
|
||||
{
|
||||
name: 'My Project',
|
||||
type: 'team',
|
||||
icon: { type: 'icon', value: 'folder' },
|
||||
} as Project,
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await controller.getAll();
|
||||
|
||||
expect(workflowRepository.findByIds).toHaveBeenCalledWith(['wf1', 'wf2'], {
|
||||
fields: ['id', 'name', 'nodes'],
|
||||
});
|
||||
expect(sharedWorkflowRepository.findOwnerProjectsByWorkflowIds).toHaveBeenCalledWith([
|
||||
'wf1',
|
||||
'wf2',
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
kind: 'webhook',
|
||||
workflowId: 'wf1',
|
||||
workflowName: 'My Workflow',
|
||||
method: 'POST',
|
||||
path: 'my-hook',
|
||||
node: 'Webhook',
|
||||
nodeType: 'n8n-nodes-base.webhook',
|
||||
isActive: true,
|
||||
project: { name: 'My Project', type: 'team', icon: { type: 'icon', value: 'folder' } },
|
||||
calledBy: [],
|
||||
},
|
||||
{
|
||||
kind: 'webhook',
|
||||
workflowId: 'wf2',
|
||||
workflowName: undefined,
|
||||
method: 'GET',
|
||||
path: 'abc123/user/:id',
|
||||
node: 'Webhook 2',
|
||||
nodeType: undefined,
|
||||
isActive: true,
|
||||
project: undefined,
|
||||
calledBy: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should list in-memory triggers of published workflows as active', async () => {
|
||||
workflowRepository.findPublishedWithActiveVersionNodes.mockResolvedValue([
|
||||
{
|
||||
id: 'wf5',
|
||||
name: 'Scheduled Workflow',
|
||||
activeVersion: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'n1',
|
||||
name: 'Schedule Trigger',
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
},
|
||||
{ id: 'n2', name: 'Set', type: 'n8n-nodes-base.set' },
|
||||
{
|
||||
id: 'n3',
|
||||
name: 'Sub-workflow Trigger',
|
||||
type: 'n8n-nodes-base.executeWorkflowTrigger',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as WorkflowEntity,
|
||||
]);
|
||||
vi.mocked(getTriggerKinds).mockReturnValue(
|
||||
new Map([
|
||||
['n1', 'in-memory'],
|
||||
['n2', 'persisted'],
|
||||
['n3', 'persisted'],
|
||||
]),
|
||||
);
|
||||
// Duplicate edges: draft and published versions index separately
|
||||
workflowDependencyRepository.findCallersOfWorkflows.mockResolvedValue([
|
||||
{ workflowId: 'caller1', dependencyKey: 'wf5' },
|
||||
{ workflowId: 'caller1', dependencyKey: 'wf5' },
|
||||
] as never);
|
||||
workflowRepository.findByIds.mockResolvedValue([
|
||||
{ id: 'caller1', name: 'Caller Workflow' } as WorkflowEntity,
|
||||
]);
|
||||
|
||||
const result = await controller.getAll();
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
kind: 'trigger',
|
||||
workflowId: 'wf5',
|
||||
workflowName: 'Scheduled Workflow',
|
||||
node: 'Schedule Trigger',
|
||||
nodeType: 'n8n-nodes-base.scheduleTrigger',
|
||||
isActive: true,
|
||||
project: undefined,
|
||||
calledBy: [{ id: 'caller1', name: 'Caller Workflow' }],
|
||||
},
|
||||
{
|
||||
kind: 'trigger',
|
||||
workflowId: 'wf5',
|
||||
workflowName: 'Scheduled Workflow',
|
||||
node: 'Sub-workflow Trigger',
|
||||
nodeType: 'n8n-nodes-base.executeWorkflowTrigger',
|
||||
isActive: true,
|
||||
project: undefined,
|
||||
calledBy: [{ id: 'caller1', name: 'Caller Workflow' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should list triggers resolved from unpublished workflows as inactive', async () => {
|
||||
workflowRepository.findUnpublishedWithNodes.mockResolvedValue([
|
||||
{
|
||||
id: 'wf3',
|
||||
name: 'Inactive Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'n1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
webhookId: 'hook-id',
|
||||
},
|
||||
{
|
||||
id: 'n2',
|
||||
name: 'Schedule Trigger',
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
} as unknown as WorkflowEntity,
|
||||
]);
|
||||
vi.mocked(getTriggerKinds).mockReturnValue(new Map([['n2', 'in-memory']]));
|
||||
vi.mocked(getWorkflowWebhooks).mockReturnValue([
|
||||
mock<IWebhookData>({ node: 'Webhook', httpMethod: 'POST', path: 'inactive-hook' }),
|
||||
]);
|
||||
|
||||
const result = await controller.getAll();
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
kind: 'trigger',
|
||||
workflowId: 'wf3',
|
||||
workflowName: 'Inactive Workflow',
|
||||
node: 'Schedule Trigger',
|
||||
nodeType: 'n8n-nodes-base.scheduleTrigger',
|
||||
isActive: false,
|
||||
project: undefined,
|
||||
calledBy: [],
|
||||
},
|
||||
{
|
||||
kind: 'webhook',
|
||||
workflowId: 'wf3',
|
||||
workflowName: 'Inactive Workflow',
|
||||
method: 'POST',
|
||||
path: 'inactive-hook',
|
||||
node: 'Webhook',
|
||||
nodeType: 'n8n-nodes-base.webhook',
|
||||
isActive: false,
|
||||
project: undefined,
|
||||
calledBy: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip unpublished workflows without trigger nodes', async () => {
|
||||
workflowRepository.findUnpublishedWithNodes.mockResolvedValue([
|
||||
{
|
||||
id: 'wf4',
|
||||
name: 'No Triggers',
|
||||
nodes: [{ id: 'n1', name: 'Set', type: 'n8n-nodes-base.set' }],
|
||||
connections: {},
|
||||
} as unknown as WorkflowEntity,
|
||||
]);
|
||||
|
||||
expect(await controller.getAll()).toEqual([]);
|
||||
expect(getWorkflowWebhooks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -15,6 +15,7 @@ import type { INode, IWebhookData, IHttpRequestMethods, IWorkflowBase } from 'n8
|
|||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error';
|
||||
import { NodeTypes } from '@/node-types';
|
||||
import { Push } from '@/push';
|
||||
import * as WebhookHelpers from '@/webhooks/webhook-helpers';
|
||||
import { WebhookService } from '@/webhooks/webhook.service';
|
||||
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
||||
|
|
@ -48,6 +49,7 @@ export class LiveWebhooks implements IWebhookManager {
|
|||
private readonly workflowsConfig: WorkflowsConfig,
|
||||
private readonly workflowPublishedDataService: WorkflowPublishedDataService,
|
||||
private readonly expressionEngineConfig: ExpressionEngineConfig,
|
||||
private readonly push: Push,
|
||||
) {}
|
||||
|
||||
async getWebhookMethods(path: string) {
|
||||
|
|
@ -167,6 +169,19 @@ export class LiveWebhooks implements IWebhookManager {
|
|||
throw new NotFoundError('Could not find node to process webhook.');
|
||||
}
|
||||
|
||||
// Lets the webhooks settings view highlight a webhook when it triggers
|
||||
this.push.broadcast({
|
||||
type: 'webhookReceived',
|
||||
data: {
|
||||
workflowId: webhook.workflowId,
|
||||
node: webhook.node,
|
||||
method: webhook.method,
|
||||
path: webhook.isDynamic
|
||||
? `${webhook.webhookId}/${webhook.webhookPath}`
|
||||
: webhook.webhookPath,
|
||||
},
|
||||
});
|
||||
|
||||
if (!authAllowlistedNodes.has(workflowStartNode.type)) {
|
||||
sanitizeWebhookRequest(request);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,223 @@
|
|||
import { Post, RestController } from '@n8n/decorators';
|
||||
import type { WorkflowEntity } from '@n8n/db';
|
||||
import {
|
||||
SharedWorkflowRepository,
|
||||
WorkflowDependencyRepository,
|
||||
WorkflowRepository,
|
||||
} from '@n8n/db';
|
||||
import { Get, GlobalScope, Post, RestController } from '@n8n/decorators';
|
||||
import { Request } from 'express';
|
||||
import get from 'lodash/get';
|
||||
import type { INode, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
|
||||
import { EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, Workflow } from 'n8n-workflow';
|
||||
|
||||
import { NodeTypes } from '@/node-types';
|
||||
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
||||
import { getTriggerKinds } from '@/workflows/triggers/trigger-kinds';
|
||||
|
||||
import * as WebhookHelpers from './webhook-helpers';
|
||||
import { WebhookService } from './webhook.service';
|
||||
import type { Method } from './webhook.types';
|
||||
|
||||
type TriggerRow = {
|
||||
kind: 'webhook' | 'trigger';
|
||||
workflowId: string;
|
||||
workflowName?: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
node: string;
|
||||
nodeType?: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
@RestController('/webhooks')
|
||||
export class WebhooksController {
|
||||
constructor(private readonly webhookService: WebhookService) {}
|
||||
constructor(
|
||||
private readonly webhookService: WebhookService,
|
||||
private readonly workflowRepository: WorkflowRepository,
|
||||
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
||||
private readonly workflowDependencyRepository: WorkflowDependencyRepository,
|
||||
private readonly nodeTypes: NodeTypes,
|
||||
) {}
|
||||
|
||||
@Get('/')
|
||||
@GlobalScope('webhook:list')
|
||||
async getAll() {
|
||||
const rows = [
|
||||
...(await this.getActiveWebhooks()),
|
||||
...(await this.getActiveInMemoryTriggers()),
|
||||
...(await this.getInactiveTriggers()),
|
||||
];
|
||||
if (rows.length === 0) return rows;
|
||||
|
||||
const workflowIds = [...new Set(rows.map((row) => row.workflowId))];
|
||||
const ownerProjects =
|
||||
await this.sharedWorkflowRepository.findOwnerProjectsByWorkflowIds(workflowIds);
|
||||
const callersByCallee = await this.getCallersByCallee(workflowIds);
|
||||
|
||||
return rows.map((row) => {
|
||||
const project = ownerProjects.get(row.workflowId);
|
||||
return {
|
||||
...row,
|
||||
project: project
|
||||
? { name: project.name, type: project.type, icon: project.icon }
|
||||
: undefined,
|
||||
calledBy: callersByCallee.get(row.workflowId) ?? [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Workflows calling each given workflow via an Execute Workflow node. */
|
||||
private async getCallersByCallee(workflowIds: string[]) {
|
||||
const edges = await this.workflowDependencyRepository.findCallersOfWorkflows(workflowIds);
|
||||
const callerIds = [...new Set(edges.map((edge) => edge.workflowId))];
|
||||
const callers = await this.workflowRepository.findByIds(callerIds, { fields: ['id', 'name'] });
|
||||
const callerNames = new Map(callers.map((workflow) => [workflow.id, workflow.name]));
|
||||
|
||||
// Draft and published versions index separately, so dedupe callers per callee
|
||||
const callersByCallee = new Map<string, Map<string, string>>();
|
||||
for (const edge of edges) {
|
||||
const perCallee = callersByCallee.get(edge.dependencyKey) ?? new Map<string, string>();
|
||||
perCallee.set(edge.workflowId, callerNames.get(edge.workflowId) ?? edge.workflowId);
|
||||
callersByCallee.set(edge.dependencyKey, perCallee);
|
||||
}
|
||||
|
||||
return new Map(
|
||||
[...callersByCallee.entries()].map(([calleeId, perCallee]) => [
|
||||
calleeId,
|
||||
[...perCallee.entries()].map(([id, name]) => ({ id, name })),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
private async getActiveWebhooks(): Promise<TriggerRow[]> {
|
||||
const registered = await this.webhookService.findAll();
|
||||
const workflowIds = [...new Set(registered.map((webhook) => webhook.workflowId))];
|
||||
const workflows = await this.workflowRepository.findByIds(workflowIds, {
|
||||
fields: ['id', 'name', 'nodes'],
|
||||
});
|
||||
const workflowsById = new Map(workflows.map((workflow) => [workflow.id, workflow]));
|
||||
|
||||
return registered.map((webhook) => {
|
||||
const workflow = workflowsById.get(webhook.workflowId);
|
||||
return {
|
||||
kind: 'webhook',
|
||||
workflowId: webhook.workflowId,
|
||||
workflowName: workflow?.name,
|
||||
method: webhook.method,
|
||||
// Dynamic paths are served under the node's webhookId prefix
|
||||
path: webhook.isDynamic
|
||||
? `${webhook.webhookId}/${webhook.webhookPath}`
|
||||
: webhook.webhookPath,
|
||||
node: webhook.node,
|
||||
nodeType: workflow?.nodes.find((node) => node.name === webhook.node)?.type,
|
||||
isActive: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Schedule/polling triggers registered by published workflows. */
|
||||
private async getActiveInMemoryTriggers(): Promise<TriggerRow[]> {
|
||||
const workflows = await this.workflowRepository.findPublishedWithActiveVersionNodes();
|
||||
return workflows.flatMap((workflow) =>
|
||||
this.buildInMemoryTriggerRows(
|
||||
workflow.id,
|
||||
workflow.name,
|
||||
workflow.activeVersion?.nodes ?? [],
|
||||
true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers defined in unpublished workflows. Webhooks are resolved with the
|
||||
* same helper the activation flow uses so paths match what would be
|
||||
* registered; in-memory triggers (schedules, pollers) are listed directly.
|
||||
*/
|
||||
private async getInactiveTriggers(): Promise<TriggerRow[]> {
|
||||
// ponytail: loads nodes of every unpublished workflow; filter in SQL if this gets slow
|
||||
const workflowEntities = await this.workflowRepository.findUnpublishedWithNodes();
|
||||
|
||||
const rows: TriggerRow[] = [];
|
||||
let additionalData: IWorkflowExecuteAdditionalData | undefined;
|
||||
for (const entity of workflowEntities) {
|
||||
rows.push(...this.buildInMemoryTriggerRows(entity.id, entity.name, entity.nodes, false));
|
||||
|
||||
if (entity.nodes.some((node) => node.webhookId && !node.disabled)) {
|
||||
additionalData ??= await WorkflowExecuteAdditionalData.getBase();
|
||||
rows.push(...(await this.resolveWorkflowWebhooks(entity, additionalData)));
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private buildInMemoryTriggerRows(
|
||||
workflowId: string,
|
||||
workflowName: string,
|
||||
nodes: INode[],
|
||||
isActive: boolean,
|
||||
): TriggerRow[] {
|
||||
if (nodes.length === 0) return [];
|
||||
|
||||
const kinds = getTriggerKinds(nodes, this.nodeTypes);
|
||||
return nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
!node.disabled &&
|
||||
// Sub-workflow triggers are pseudo-triggers ('persisted') but belong in the list
|
||||
(kinds.get(node.id) === 'in-memory' || node.type === EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE),
|
||||
)
|
||||
.map((node) => ({
|
||||
kind: 'trigger',
|
||||
workflowId,
|
||||
workflowName,
|
||||
node: node.name,
|
||||
nodeType: node.type,
|
||||
isActive,
|
||||
}));
|
||||
}
|
||||
|
||||
private async resolveWorkflowWebhooks(
|
||||
entity: WorkflowEntity,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
): Promise<TriggerRow[]> {
|
||||
try {
|
||||
const workflow = new Workflow({
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
nodes: entity.nodes,
|
||||
connections: entity.connections,
|
||||
active: false,
|
||||
nodeTypes: this.nodeTypes,
|
||||
});
|
||||
|
||||
// Resolving webhooks evaluates path/method expressions, which needs an isolate
|
||||
const webhooks = await workflow.expression.withIsolate(
|
||||
async () =>
|
||||
await Promise.resolve(
|
||||
WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, undefined, true),
|
||||
),
|
||||
);
|
||||
|
||||
return webhooks.map((webhookData) => {
|
||||
const node = workflow.getNode(webhookData.node);
|
||||
return {
|
||||
kind: 'webhook',
|
||||
workflowId: entity.id,
|
||||
workflowName: entity.name,
|
||||
method: webhookData.httpMethod,
|
||||
path: webhookData.path.includes(':')
|
||||
? `${node?.webhookId}/${webhookData.path}`
|
||||
: webhookData.path,
|
||||
node: webhookData.node,
|
||||
nodeType: node?.type,
|
||||
isActive: false,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
// A workflow whose webhook expressions cannot resolve outside execution is skipped
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@Post('/find')
|
||||
async findWebhook(req: Request) {
|
||||
|
|
|
|||
51
packages/cli/src/workflows/triggers/trigger-kinds.ts
Normal file
51
packages/cli/src/workflows/triggers/trigger-kinds.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { WorkflowPublicationTriggerKind } from '@n8n/db';
|
||||
import type { INode, INodeTypes } from 'n8n-workflow';
|
||||
import {
|
||||
ERROR_TRIGGER_NODE_TYPE,
|
||||
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
Workflow,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
// Their trigger() is a no-op — fired by the execution engine, never the
|
||||
// registry — so reconciling them against the registry would re-enqueue forever.
|
||||
const PSEUDO_TRIGGER_NODE_TYPES = new Set<string>([
|
||||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
ERROR_TRIGGER_NODE_TYPE,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Maps each node to where it lives once activated, decided by which functions
|
||||
* its node type implements: nodes with a `poll` or `trigger` function register
|
||||
* `in-memory`, nodes with only a `webhook` function are `persisted` rows in
|
||||
* `webhook_entity`. The pseudo triggers (manual, executeWorkflow, error) are
|
||||
* `persisted` despite their `trigger` function: it is a no-op fired by the
|
||||
* execution engine, so the registry holds nothing worth reconciling for them.
|
||||
*/
|
||||
export function getTriggerKinds(
|
||||
nodes: INode[],
|
||||
nodeTypes: INodeTypes,
|
||||
): Map<INode['id'], WorkflowPublicationTriggerKind> {
|
||||
const workflow = new Workflow({
|
||||
id: 'trigger-diff',
|
||||
name: 'trigger-diff',
|
||||
nodes,
|
||||
connections: {},
|
||||
active: false,
|
||||
nodeTypes,
|
||||
});
|
||||
|
||||
const inMemoryNodeIds = new Set(
|
||||
[...workflow.getPollNodes(), ...workflow.getTriggerNodes()]
|
||||
.filter((node) => !PSEUDO_TRIGGER_NODE_TYPES.has(node.type))
|
||||
.map((node) => node.id),
|
||||
);
|
||||
|
||||
const kinds = new Map<INode['id'], WorkflowPublicationTriggerKind>();
|
||||
for (const node of nodes) {
|
||||
kinds.set(node.id, inMemoryNodeIds.has(node.id) ? 'in-memory' : 'persisted');
|
||||
}
|
||||
|
||||
return kinds;
|
||||
}
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
import assert from 'node:assert/strict';
|
||||
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { WorkflowsConfig } from '@n8n/config';
|
||||
import type { IWorkflowDb, WorkflowEntity, WorkflowPublicationTriggerKind } from '@n8n/db';
|
||||
import { WorkflowRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { ErrorReporter, SpanStatus, Tracing } from 'n8n-core';
|
||||
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
||||
import { createResultError, createResultOk, type Result } from '@n8n/utils/result';
|
||||
import { ErrorReporter, SpanStatus, Tracing } from 'n8n-core';
|
||||
import type {
|
||||
IConnections,
|
||||
INode,
|
||||
|
|
@ -18,13 +16,8 @@ import type {
|
|||
WorkflowExecuteMode,
|
||||
WorkflowId,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
ERROR_TRIGGER_NODE_TYPE,
|
||||
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
Workflow,
|
||||
WorkflowActivationError,
|
||||
} from 'n8n-workflow';
|
||||
import { Workflow, WorkflowActivationError } from 'n8n-workflow';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { ActivationErrorsService } from '@/activation-errors.service';
|
||||
import { TRIGGER_ACTIVATION_MAX_ATTEMPTS } from '@/constants';
|
||||
|
|
@ -40,19 +33,12 @@ import { NonWebhookTriggerRegistrar } from '@/workflows/triggers/non-webhook-tri
|
|||
import { retryTriggerActivation } from '@/workflows/triggers/trigger-activation-retry';
|
||||
import { TriggerCountService } from '@/workflows/triggers/trigger-count.service';
|
||||
import { TriggerExecutionContextFactory } from '@/workflows/triggers/trigger-execution-context.factory';
|
||||
import { getTriggerKinds } from '@/workflows/triggers/trigger-kinds';
|
||||
import { WebhookTriggerRegistrar } from '@/workflows/triggers/webhook-trigger-registrar';
|
||||
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
|
||||
|
||||
export type WorkflowTriggerVersion = { nodes: INode[]; connections: IConnections };
|
||||
|
||||
// Their trigger() is a no-op — fired by the execution engine, never the
|
||||
// registry — so reconciling them against the registry would re-enqueue forever.
|
||||
const PSEUDO_TRIGGER_NODE_TYPES = new Set<string>([
|
||||
MANUAL_TRIGGER_NODE_TYPE,
|
||||
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
ERROR_TRIGGER_NODE_TYPE,
|
||||
]);
|
||||
|
||||
/** A single trigger node that failed to (de)register during activation. */
|
||||
export type TriggerActivationFailure = {
|
||||
nodeId: INode['id'];
|
||||
|
|
@ -124,37 +110,11 @@ export class WorkflowTriggerActivator {
|
|||
}
|
||||
|
||||
/**
|
||||
* Maps each node to where it lives once activated, decided by which functions
|
||||
* its node type implements: nodes with a `poll` or `trigger` function register
|
||||
* `in-memory`, nodes with only a `webhook` function are `persisted` rows in
|
||||
* `webhook_entity`. The pseudo triggers (manual, executeWorkflow, error) are
|
||||
* `persisted` despite their `trigger` function: it is a no-op fired by the
|
||||
* execution engine, so the registry holds nothing worth reconciling for them.
|
||||
* Used by reconciliation to tell which triggers should be in the in-memory
|
||||
* registry.
|
||||
* See {@link getTriggerKinds}. Used by reconciliation to tell which triggers
|
||||
* should be in the in-memory registry.
|
||||
*/
|
||||
getTriggerKinds(nodes: INode[]): Map<INode['id'], WorkflowPublicationTriggerKind> {
|
||||
const workflow = new Workflow({
|
||||
id: 'trigger-diff',
|
||||
name: 'trigger-diff',
|
||||
nodes,
|
||||
connections: {},
|
||||
active: false,
|
||||
nodeTypes: this.nodeTypes,
|
||||
});
|
||||
|
||||
const inMemoryNodeIds = new Set(
|
||||
[...workflow.getPollNodes(), ...workflow.getTriggerNodes()]
|
||||
.filter((node) => !PSEUDO_TRIGGER_NODE_TYPES.has(node.type))
|
||||
.map((node) => node.id),
|
||||
);
|
||||
|
||||
const kinds = new Map<INode['id'], WorkflowPublicationTriggerKind>();
|
||||
for (const node of nodes) {
|
||||
kinds.set(node.id, inMemoryNodeIds.has(node.id) ? 'in-memory' : 'persisted');
|
||||
}
|
||||
|
||||
return kinds;
|
||||
return getTriggerKinds(nodes, this.nodeTypes);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ export enum VIEWS {
|
|||
OPENTELEMETRY_SETTINGS = 'SettingsOpenTelemetryView',
|
||||
SSO_SETTINGS = 'SSoSettings',
|
||||
ENCRYPTION_KEYS_SETTINGS = 'EncryptionKeysSettings',
|
||||
WEBHOOKS_SETTINGS = 'WebhooksSettings',
|
||||
EXTERNAL_SECRETS_SETTINGS = 'ExternalSecretsSettings',
|
||||
SAML_ONBOARDING = 'SamlOnboarding',
|
||||
SOURCE_CONTROL = 'SourceControl',
|
||||
|
|
|
|||
|
|
@ -5572,6 +5572,25 @@
|
|||
"settings.encryptionKeys.empty.title": "No encryption keys yet",
|
||||
"settings.encryptionKeys.empty.description": "Rotate a key to generate your first active encryption key.",
|
||||
"settings.encryptionKeys.loadError": "Could not load encryption keys",
|
||||
"settings.webhooks": "Triggers",
|
||||
"settings.webhooks.title": "Triggers",
|
||||
"settings.webhooks.description": "All triggers on this instance — webhook endpoints, schedules and pollers, from both active and inactive workflows.",
|
||||
"settings.webhooks.search.placeholder": "Search triggers...",
|
||||
"settings.webhooks.filter.nodeType": "All node types",
|
||||
"settings.webhooks.section.active": "Active",
|
||||
"settings.webhooks.section.inactive": "Inactive",
|
||||
"settings.webhooks.column.method": "Method",
|
||||
"settings.webhooks.column.path": "Path",
|
||||
"settings.webhooks.column.workflow": "Workflow",
|
||||
"settings.webhooks.column.project": "Project",
|
||||
"settings.webhooks.column.node": "Node",
|
||||
"settings.webhooks.column.calls": "Calls",
|
||||
"settings.webhooks.column.calledBy": "Called by",
|
||||
"settings.webhooks.copy.tooltip": "Copy webhook URL",
|
||||
"settings.webhooks.copy.success": "Webhook URL copied",
|
||||
"settings.webhooks.empty.title": "No triggers",
|
||||
"settings.webhooks.empty.description": "Triggers appear here once workflows contain webhook, schedule or polling trigger nodes.",
|
||||
"settings.webhooks.loadError": "Could not load triggers",
|
||||
"settings.sso": "SSO",
|
||||
"settings.sso.title": "Single Sign-On",
|
||||
"settings.sso.subtitle": "SAML 2.0 Configuration",
|
||||
|
|
|
|||
|
|
@ -10,9 +10,30 @@ type WebhookData = {
|
|||
node: string;
|
||||
};
|
||||
|
||||
export type InstanceWebhook = {
|
||||
kind: 'webhook' | 'trigger';
|
||||
workflowId: string;
|
||||
workflowName?: string;
|
||||
method?: IHttpRequestMethods;
|
||||
path?: string;
|
||||
node: string;
|
||||
nodeType?: string;
|
||||
isActive: boolean;
|
||||
project?: {
|
||||
name: string;
|
||||
type: 'personal' | 'team';
|
||||
icon: { type: 'emoji' | 'icon'; value: string } | null;
|
||||
};
|
||||
calledBy: Array<{ id: string; name: string }>;
|
||||
};
|
||||
|
||||
export const findWebhook = async (
|
||||
context: IRestApiContext,
|
||||
data: { path: string; method: string },
|
||||
): Promise<WebhookData | null> => {
|
||||
return await makeRestApiRequest(context, 'POST', '/webhooks/find', data);
|
||||
};
|
||||
|
||||
export const getInstanceWebhooks = async (context: IRestApiContext): Promise<InstanceWebhook[]> => {
|
||||
return await makeRestApiRequest(context, 'GET', '/webhooks');
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
|
|||
externalSecret: {},
|
||||
project: {},
|
||||
orchestration: {},
|
||||
webhook: {},
|
||||
workersView: {},
|
||||
eventBusDestination: {},
|
||||
auditLogs: {},
|
||||
|
|
|
|||
|
|
@ -147,6 +147,14 @@ export function useSettingsItems() {
|
|||
available: canUserAccessRouteByName(VIEWS.LDAP_SETTINGS),
|
||||
route: { to: { name: VIEWS.LDAP_SETTINGS } },
|
||||
},
|
||||
{
|
||||
id: 'settings-webhooks',
|
||||
icon: 'webhook',
|
||||
label: i18n.baseText('settings.webhooks'),
|
||||
position: 'top',
|
||||
available: canUserAccessRouteByName(VIEWS.WEBHOOKS_SETTINGS),
|
||||
route: { to: { name: VIEWS.WEBHOOKS_SETTINGS } },
|
||||
},
|
||||
{
|
||||
id: 'settings-workersview',
|
||||
icon: 'waypoints',
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ const SettingsUsageAndPlan = async () =>
|
|||
const SettingsSso = async () => await import('@/features/settings/sso/views/SettingsSso.vue');
|
||||
const SettingsEncryptionKeys = async () =>
|
||||
await import('@/features/settings/encryption-keys/views/SettingsEncryptionKeys.vue');
|
||||
const SettingsWebhooksView = async () =>
|
||||
await import('@/features/settings/webhooks/views/SettingsWebhooksView.vue');
|
||||
const SignoutView = async () => await import('@/features/core/auth/views/SignoutView.vue');
|
||||
const SamlOnboarding = async () => await import('@/features/settings/sso/views/SamlOnboarding.vue');
|
||||
const SettingsSourceControl = async () =>
|
||||
|
|
@ -1065,6 +1067,22 @@ export const routes: RouteRecordRaw[] = [
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'webhooks',
|
||||
name: VIEWS.WEBHOOKS_SETTINGS,
|
||||
component: SettingsWebhooksView,
|
||||
meta: {
|
||||
middleware: ['authenticated', 'rbac'],
|
||||
middlewareOptions: {
|
||||
rbac: {
|
||||
scope: 'webhook:list',
|
||||
},
|
||||
},
|
||||
telemetry: {
|
||||
pageCategory: 'settings',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'workers',
|
||||
name: VIEWS.WORKER_VIEW,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,475 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import {
|
||||
N8nDataTableServer,
|
||||
N8nHeading,
|
||||
N8nIconButton,
|
||||
N8nInput,
|
||||
N8nOption,
|
||||
N8nSelect,
|
||||
N8nTag,
|
||||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import type { IconOrEmoji, TableHeader } from '@n8n/design-system';
|
||||
import { getInstanceWebhooks, type InstanceWebhook } from '@n8n/rest-api-client/api/webhooks';
|
||||
import { EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useClipboard } from '@n8n/composables/useClipboard';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
|
||||
import NodeIcon from '@/app/components/NodeIcon.vue';
|
||||
import ProjectIcon from '@/features/collaboration/projects/components/ProjectIcon.vue';
|
||||
import { VIEWS } from '@/app/constants';
|
||||
|
||||
type WebhookRow = InstanceWebhook & { url: string; projectName: string; callCount: number };
|
||||
|
||||
const i18n = useI18n();
|
||||
const rootStore = useRootStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const pushStore = usePushConnectionStore();
|
||||
const clipboard = useClipboard();
|
||||
const { showMessage, showError } = useToast();
|
||||
const documentTitle = useDocumentTitle();
|
||||
|
||||
const webhooks = ref<WebhookRow[]>([]);
|
||||
const isLoading = ref(true);
|
||||
const search = ref('');
|
||||
const sortBy = ref<Array<{ id: string; desc: boolean }>>([{ id: 'workflowName', desc: false }]);
|
||||
|
||||
const headers = computed<Array<TableHeader<WebhookRow>>>(() => [
|
||||
{
|
||||
title: i18n.baseText('settings.webhooks.column.node'),
|
||||
key: 'node',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.webhooks.column.workflow'),
|
||||
key: 'workflowName',
|
||||
width: 220,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.webhooks.column.project'),
|
||||
key: 'projectName',
|
||||
width: 170,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.webhooks.column.path'),
|
||||
key: 'path',
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.webhooks.column.method'),
|
||||
key: 'method',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.webhooks.column.calls'),
|
||||
key: 'callCount',
|
||||
width: 90,
|
||||
align: 'end',
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.webhooks.column.calledBy'),
|
||||
key: 'calledBy',
|
||||
width: 200,
|
||||
disableSort: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const nodeTypeFilter = ref<string[]>([]);
|
||||
|
||||
const nodeTypeOptions = computed(() => {
|
||||
const labels = new Map<string, string>();
|
||||
for (const row of webhooks.value) {
|
||||
if (row.nodeType && !labels.has(row.nodeType)) {
|
||||
labels.set(
|
||||
row.nodeType,
|
||||
nodeTypesStore.getNodeType(row.nodeType)?.displayName ?? row.nodeType,
|
||||
);
|
||||
}
|
||||
}
|
||||
return [...labels.entries()]
|
||||
.map(([value, label]) => ({ value, label }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
});
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const term = search.value.trim().toLowerCase();
|
||||
let items = webhooks.value;
|
||||
if (nodeTypeFilter.value.length > 0) {
|
||||
items = items.filter((row) => nodeTypeFilter.value.includes(row.nodeType ?? ''));
|
||||
}
|
||||
if (term) {
|
||||
items = items.filter((row) =>
|
||||
[
|
||||
row.path ?? '',
|
||||
row.workflowName ?? '',
|
||||
row.node,
|
||||
row.method ?? '',
|
||||
row.projectName,
|
||||
...row.calledBy.map((caller) => caller.name),
|
||||
].some((value) => value.toLowerCase().includes(term)),
|
||||
);
|
||||
}
|
||||
|
||||
if (!sortBy.value.length) return items;
|
||||
|
||||
const [{ id, desc }] = sortBy.value;
|
||||
const key = id as keyof WebhookRow;
|
||||
return [...items].sort((a, b) => {
|
||||
const left = a[key];
|
||||
const right = b[key];
|
||||
if (typeof left === 'number' && typeof right === 'number') {
|
||||
return desc ? right - left : left - right;
|
||||
}
|
||||
const leftText = String(left ?? '');
|
||||
const rightText = String(right ?? '');
|
||||
return desc ? rightText.localeCompare(leftText) : leftText.localeCompare(rightText);
|
||||
});
|
||||
});
|
||||
|
||||
const sections = computed(() =>
|
||||
[
|
||||
{
|
||||
id: 'active',
|
||||
title: i18n.baseText('settings.webhooks.section.active'),
|
||||
items: filteredItems.value.filter((row) => row.isActive),
|
||||
},
|
||||
{
|
||||
id: 'inactive',
|
||||
title: i18n.baseText('settings.webhooks.section.inactive'),
|
||||
items: filteredItems.value.filter((row) => !row.isActive),
|
||||
},
|
||||
].filter((section) => section.items.length > 0),
|
||||
);
|
||||
|
||||
const getWorkflowLink = (workflowId: string): RouteLocationRaw => ({
|
||||
name: VIEWS.WORKFLOW,
|
||||
params: { workflowId },
|
||||
});
|
||||
|
||||
const getProjectIcon = (row: WebhookRow): IconOrEmoji =>
|
||||
row.project?.icon ?? {
|
||||
type: 'icon',
|
||||
value: row.project?.type === 'personal' ? 'user' : 'layers',
|
||||
};
|
||||
|
||||
const getProjectAbbreviation = (name: string) =>
|
||||
name.length > 16 ? `${name.slice(0, 16)}…` : name;
|
||||
|
||||
const getPathAbbreviation = (path: string) => {
|
||||
const [firstSegment] = path.split('/');
|
||||
const shortened = firstSegment.length > 8 ? firstSegment.slice(0, 8) : firstSegment;
|
||||
return shortened === path ? path : `${shortened}…`;
|
||||
};
|
||||
|
||||
const MAX_CALLER_PILLS = 3;
|
||||
const getOverflowCallers = (row: WebhookRow) =>
|
||||
row.calledBy
|
||||
.slice(MAX_CALLER_PILLS)
|
||||
.map((caller) => caller.name)
|
||||
.join(', ');
|
||||
|
||||
const FLASH_DURATION_MS = 2000;
|
||||
const flashedKeys = ref(new Set<string>());
|
||||
const rowKey = (row: { workflowId: string; node: string; method?: string; path?: string }) =>
|
||||
`${row.workflowId}|${row.node}|${row.method ?? ''}|${row.path ?? ''}`;
|
||||
|
||||
const flashRow = (key: string) => {
|
||||
// Replace the Set so the change is reactive
|
||||
flashedKeys.value = new Set(flashedKeys.value).add(key);
|
||||
setTimeout(() => {
|
||||
const next = new Set(flashedKeys.value);
|
||||
next.delete(key);
|
||||
flashedKeys.value = next;
|
||||
}, FLASH_DURATION_MS);
|
||||
};
|
||||
|
||||
const pingRow = (row: WebhookRow) => {
|
||||
row.callCount += 1;
|
||||
flashRow(rowKey(row));
|
||||
};
|
||||
|
||||
const removePushListener = pushStore.addEventListener((message) => {
|
||||
if (message.type === 'webhookReceived') {
|
||||
const key = rowKey(message.data);
|
||||
const row = webhooks.value.find((candidate) => rowKey(candidate) === key);
|
||||
if (row) pingRow(row);
|
||||
} else if (message.type === 'triggerFired') {
|
||||
// These events carry no node info, so ping the workflow's matching trigger rows:
|
||||
// sub-workflow triggers for `integrated` runs, schedules/pollers otherwise
|
||||
const isSubWorkflowRun = message.data.mode === 'integrated';
|
||||
for (const row of webhooks.value) {
|
||||
if (row.kind !== 'trigger' || row.workflowId !== message.data.workflowId) continue;
|
||||
const isSubWorkflowTrigger = row.nodeType === EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE;
|
||||
if (isSubWorkflowTrigger === isSubWorkflowRun) pingRow(row);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
removePushListener();
|
||||
pushStore.pushDisconnect();
|
||||
});
|
||||
|
||||
const onCopyUrl = async (url: string) => {
|
||||
await clipboard.copy(url);
|
||||
showMessage({
|
||||
type: 'success',
|
||||
title: i18n.baseText('settings.webhooks.copy.success'),
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
documentTitle.set(i18n.baseText('settings.webhooks.title'));
|
||||
pushStore.pushConnect();
|
||||
try {
|
||||
const [items] = await Promise.all([
|
||||
getInstanceWebhooks(rootStore.restApiContext),
|
||||
nodeTypesStore.loadNodeTypesIfNotLoaded(),
|
||||
]);
|
||||
webhooks.value = items.map((item) => ({
|
||||
...item,
|
||||
url: item.path ? `${rootStore.webhookUrl}/${item.path}` : '',
|
||||
projectName: item.project?.name ?? '',
|
||||
callCount: 0,
|
||||
}));
|
||||
} catch (error) {
|
||||
showError(error, i18n.baseText('settings.webhooks.loadError'));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.page" data-testid="settings-webhooks">
|
||||
<header :class="$style.header">
|
||||
<N8nHeading tag="h1" size="2xlarge" bold>
|
||||
{{ i18n.baseText('settings.webhooks.title') }}
|
||||
</N8nHeading>
|
||||
<N8nText color="text-base">
|
||||
{{ i18n.baseText('settings.webhooks.description') }}
|
||||
</N8nText>
|
||||
</header>
|
||||
|
||||
<div v-if="webhooks.length > 0" :class="$style.controls">
|
||||
<N8nSelect
|
||||
v-model="nodeTypeFilter"
|
||||
multiple
|
||||
collapse-tags
|
||||
clearable
|
||||
:placeholder="i18n.baseText('settings.webhooks.filter.nodeType')"
|
||||
data-testid="webhooks-node-type-filter"
|
||||
:class="$style.nodeTypeFilter"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="option in nodeTypeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
</N8nSelect>
|
||||
<N8nInput
|
||||
v-model="search"
|
||||
:placeholder="i18n.baseText('settings.webhooks.search.placeholder')"
|
||||
clearable
|
||||
data-testid="webhooks-search"
|
||||
:class="$style.search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="sections.length > 0">
|
||||
<section v-for="section in sections" :key="section.id" :class="$style.section">
|
||||
<N8nHeading tag="h2" size="large">
|
||||
{{ section.title }} ({{ section.items.length }})
|
||||
</N8nHeading>
|
||||
<N8nDataTableServer
|
||||
v-model:sort-by="sortBy"
|
||||
:headers="headers"
|
||||
:items="section.items"
|
||||
:items-length="section.items.length"
|
||||
:page-sizes="[section.items.length + 1]"
|
||||
:data-testid="`webhooks-table-${section.id}`"
|
||||
>
|
||||
<template #[`item.method`]="{ item }">
|
||||
<N8nText bold size="small">{{ item.method ?? '—' }}</N8nText>
|
||||
</template>
|
||||
|
||||
<template #[`item.path`]="{ item }">
|
||||
<div v-if="item.path" :class="$style.urlCell">
|
||||
<code :class="$style.url" :title="item.url">{{
|
||||
getPathAbbreviation(item.path)
|
||||
}}</code>
|
||||
<N8nIconButton
|
||||
icon="copy"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
:title="i18n.baseText('settings.webhooks.copy.tooltip')"
|
||||
data-testid="webhook-copy-url"
|
||||
@click="onCopyUrl(item.url)"
|
||||
/>
|
||||
</div>
|
||||
<N8nText v-else color="text-light">—</N8nText>
|
||||
</template>
|
||||
|
||||
<template #[`item.workflowName`]="{ item }">
|
||||
<RouterLink :to="getWorkflowLink(item.workflowId)" target="_blank">
|
||||
<N8nText :class="$style.ellipsis" style="color: var(--color--text)">
|
||||
{{ item.workflowName ?? item.workflowId }}
|
||||
</N8nText>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<template #[`item.projectName`]="{ item }">
|
||||
<div v-if="item.project" :class="$style.projectCell" :title="item.projectName">
|
||||
<ProjectIcon :icon="getProjectIcon(item)" size="mini" border-less />
|
||||
<N8nText size="small">{{ getProjectAbbreviation(item.projectName) }}</N8nText>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #[`item.node`]="{ item }">
|
||||
<div :class="[$style.nodeCell, { [$style.flash]: flashedKeys.has(rowKey(item)) }]">
|
||||
<NodeIcon
|
||||
v-if="item.nodeType"
|
||||
:node-type="nodeTypesStore.getNodeType(item.nodeType)"
|
||||
:size="18"
|
||||
/>
|
||||
<N8nText :class="$style.ellipsis">{{ item.node }}</N8nText>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #[`item.calledBy`]="{ item }">
|
||||
<div v-if="item.calledBy.length > 0" :class="$style.pillCell">
|
||||
<RouterLink
|
||||
v-for="caller in item.calledBy.slice(0, MAX_CALLER_PILLS)"
|
||||
:key="caller.id"
|
||||
:to="getWorkflowLink(caller.id)"
|
||||
target="_blank"
|
||||
>
|
||||
<N8nTag :text="caller.name" />
|
||||
</RouterLink>
|
||||
<N8nTag
|
||||
v-if="item.calledBy.length > MAX_CALLER_PILLS"
|
||||
:text="`+${item.calledBy.length - MAX_CALLER_PILLS}`"
|
||||
:clickable="false"
|
||||
:title="getOverflowCallers(item)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</N8nDataTableServer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-else-if="!isLoading" :class="$style.emptyState" data-testid="webhooks-empty">
|
||||
<N8nHeading tag="h2" size="large">
|
||||
{{ i18n.baseText('settings.webhooks.empty.title') }}
|
||||
</N8nHeading>
|
||||
<N8nText color="text-base">
|
||||
{{ i18n.baseText('settings.webhooks.empty.description') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--lg);
|
||||
padding-bottom: var(--spacing--2xl);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.search {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.nodeTypeFilter {
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.urlCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.url {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: var(--font-size--sm);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.projectCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.pillCell {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.nodeCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--3xs);
|
||||
min-width: 0;
|
||||
padding: var(--spacing--4xs) var(--spacing--3xs);
|
||||
margin-left: calc(-1 * var(--spacing--3xs));
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.flash {
|
||||
animation: webhook-flash 2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes webhook-flash {
|
||||
from {
|
||||
background-color: var(--color--success--tint-4);
|
||||
}
|
||||
to {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing--2xs);
|
||||
padding: var(--spacing--2xl) var(--spacing--md);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user