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:
Charlie Kolb 2026-08-12 12:04:38 +02:00
parent dfa841a040
commit 839730bba7
No known key found for this signature in database
23 changed files with 1259 additions and 51 deletions

View File

@ -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;

View File

@ -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) },
});
}
}

View File

@ -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 [];

View File

@ -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",

View File

@ -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',

View File

@ -99,6 +99,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'workflow:move',
'workflow:enableRedaction',
'workflow:disableRedaction',
'webhook:list',
'workersView:manage',
'project:list',
'project:create',

View File

@ -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,

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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 } });
}
}

View File

@ -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 () => {

View 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();
});
});
});

View File

@ -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);
}

View File

@ -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) {

View 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;
}

View File

@ -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);
}
/**

View File

@ -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',

View File

@ -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",

View File

@ -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');
};

View File

@ -27,6 +27,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
externalSecret: {},
project: {},
orchestration: {},
webhook: {},
workersView: {},
eventBusDestination: {},
auditLogs: {},

View File

@ -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',

View File

@ -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,

View File

@ -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>