diff --git a/.source b/.source index 5ea693d7210..69825514698 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 5ea693d7210107a03cc025acab86d1795159e697 +Subproject commit 6982551469882fd2b63c4c1599d4b457f1182f80 diff --git a/apps/api/src/app/agents/agents.controller.ts b/apps/api/src/app/agents/agents.controller.ts index e489e15ec85..c7d22a271b0 100644 --- a/apps/api/src/app/agents/agents.controller.ts +++ b/apps/api/src/app/agents/agents.controller.ts @@ -9,6 +9,7 @@ import { Param, Patch, Post, + Put, Query, UseGuards, UseInterceptors, @@ -17,6 +18,7 @@ import { ApiExcludeController, ApiOperation } from '@nestjs/swagger'; import { RequirePermissions } from '@novu/application-generic'; import { ApiRateLimitCategoryEnum, DirectionEnum, PermissionsEnum, UserSessionData } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; +import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; import { ThrottlerCategory } from '../rate-limiting/guards'; import { ApiCommonResponses, @@ -34,6 +36,7 @@ import { ListAgentIntegrationsResponseDto, ListAgentsQueryDto, ListAgentsResponseDto, + UpdateAgentBridgeRequestDto, UpdateAgentIntegrationRequestDto, UpdateAgentRequestDto, } from './dtos'; @@ -240,6 +243,36 @@ export class AgentsController { ); } + @Put('/:identifier/bridge') + @ApiResponse(AgentResponseDto) + @ApiOperation({ + summary: 'Update agent bridge configuration', + description: + 'Updates the bridge URL configuration for an agent. Used by the CLI to register dev tunnel URLs. Refuses to activate dev bridges on production environments.', + }) + @ApiNotFoundResponse({ + description: 'The agent was not found.', + }) + @ExternalApiAccessible() + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + updateAgentBridge( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Body() body: UpdateAgentBridgeRequestDto + ): Promise { + return this.updateAgentUsecase.execute( + UpdateAgentCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + identifier, + bridgeUrl: body.bridgeUrl, + devBridgeUrl: body.devBridgeUrl, + devBridgeActive: body.devBridgeActive, + }) + ); + } + @Get('/:identifier') @ApiResponse(AgentResponseDto) @ApiOperation({ @@ -285,6 +318,9 @@ export class AgentsController { description: body.description, active: body.active, behavior: body.behavior, + bridgeUrl: body.bridgeUrl, + devBridgeUrl: body.devBridgeUrl, + devBridgeActive: body.devBridgeActive, }) ); } diff --git a/apps/api/src/app/agents/dtos/agent-response.dto.ts b/apps/api/src/app/agents/dtos/agent-response.dto.ts index 6f9d9eb3665..12ffae5f341 100644 --- a/apps/api/src/app/agents/dtos/agent-response.dto.ts +++ b/apps/api/src/app/agents/dtos/agent-response.dto.ts @@ -22,6 +22,15 @@ export class AgentResponseDto { @ApiProperty() active: boolean; + @ApiPropertyOptional({ description: 'Production bridge URL' }) + bridgeUrl?: string; + + @ApiPropertyOptional({ description: 'Development bridge URL (set by npx novu dev)' }) + devBridgeUrl?: string; + + @ApiPropertyOptional({ description: 'Whether the dev bridge override is active' }) + devBridgeActive?: boolean; + @ApiProperty() _environmentId: string; diff --git a/apps/api/src/app/agents/dtos/index.ts b/apps/api/src/app/agents/dtos/index.ts index bbb0beb6cab..5988208cc1e 100644 --- a/apps/api/src/app/agents/dtos/index.ts +++ b/apps/api/src/app/agents/dtos/index.ts @@ -9,4 +9,5 @@ export * from './list-agent-integrations-response.dto'; export * from './list-agents-query.dto'; export * from './list-agents-response.dto'; export * from './update-agent-integration-request.dto'; +export * from './update-agent-bridge-request.dto'; export * from './update-agent-request.dto'; diff --git a/apps/api/src/app/agents/dtos/update-agent-bridge-request.dto.ts b/apps/api/src/app/agents/dtos/update-agent-bridge-request.dto.ts new file mode 100644 index 00000000000..d04db4ddb1d --- /dev/null +++ b/apps/api/src/app/agents/dtos/update-agent-bridge-request.dto.ts @@ -0,0 +1,19 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsOptional, IsUrl } from 'class-validator'; + +export class UpdateAgentBridgeRequestDto { + @ApiPropertyOptional({ description: 'Production bridge URL for this agent' }) + @IsUrl({ require_tld: false }) + @IsOptional() + bridgeUrl?: string; + + @ApiPropertyOptional({ description: 'Development bridge URL (set by npx novu dev)' }) + @IsUrl({ require_tld: false }) + @IsOptional() + devBridgeUrl?: string; + + @ApiPropertyOptional({ description: 'Whether the dev bridge override is active' }) + @IsBoolean() + @IsOptional() + devBridgeActive?: boolean; +} diff --git a/apps/api/src/app/agents/dtos/update-agent-request.dto.ts b/apps/api/src/app/agents/dtos/update-agent-request.dto.ts index 591fb1a11ae..e6e0340f774 100644 --- a/apps/api/src/app/agents/dtos/update-agent-request.dto.ts +++ b/apps/api/src/app/agents/dtos/update-agent-request.dto.ts @@ -1,6 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsBoolean, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { IsBoolean, IsOptional, IsString, IsUrl, ValidateNested } from 'class-validator'; import { AgentBehaviorDto } from './agent-behavior.dto'; @@ -25,4 +25,19 @@ export class UpdateAgentRequestDto { @Type(() => AgentBehaviorDto) @IsOptional() behavior?: AgentBehaviorDto; + + @ApiPropertyOptional({ description: 'Production bridge URL for this agent' }) + @IsUrl({ require_tld: false }) + @IsOptional() + bridgeUrl?: string; + + @ApiPropertyOptional({ description: 'Development bridge URL (set by npx novu dev)' }) + @IsUrl({ require_tld: false }) + @IsOptional() + devBridgeUrl?: string; + + @ApiPropertyOptional({ description: 'Whether the dev bridge override is active' }) + @IsBoolean() + @IsOptional() + devBridgeActive?: boolean; } diff --git a/apps/api/src/app/agents/e2e/agents.e2e.ts b/apps/api/src/app/agents/e2e/agents.e2e.ts index b6da2ca4f64..c45c57d9df0 100644 --- a/apps/api/src/app/agents/e2e/agents.e2e.ts +++ b/apps/api/src/app/agents/e2e/agents.e2e.ts @@ -299,4 +299,141 @@ describe('Agents API - /agents #novu-v2', () => { expect(agentAfter).to.equal(null); }); + + describe('Bridge URL management', () => { + let identifier: string; + + beforeEach(async () => { + identifier = `e2e-bridge-${Date.now()}`; + await session.testAgent.post('/v1/agents').send({ name: 'Bridge Agent', identifier }); + }); + + afterEach(async () => { + await session.testAgent.delete(`/v1/agents/${encodeURIComponent(identifier)}`); + }); + + it('should update bridgeUrl via PATCH', async () => { + const res = await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({ + bridgeUrl: 'https://prod.example.com/api/novu', + }); + + expect(res.status).to.equal(200); + expect(res.body.data.bridgeUrl).to.equal('https://prod.example.com/api/novu'); + }); + + it('should update devBridgeUrl and devBridgeActive via PUT bridge endpoint', async () => { + const res = await session.testAgent.put(`/v1/agents/${encodeURIComponent(identifier)}/bridge`).send({ + devBridgeUrl: 'https://tunnel.example.com', + devBridgeActive: true, + }); + + expect(res.status).to.equal(200); + expect(res.body.data.devBridgeUrl).to.equal('https://tunnel.example.com'); + expect(res.body.data.devBridgeActive).to.equal(true); + }); + + it('should set bridgeUrl via PUT bridge endpoint', async () => { + const res = await session.testAgent.put(`/v1/agents/${encodeURIComponent(identifier)}/bridge`).send({ + bridgeUrl: 'https://prod.example.com/novu', + }); + + expect(res.status).to.equal(200); + expect(res.body.data.bridgeUrl).to.equal('https://prod.example.com/novu'); + }); + + it('should return bridge fields on GET', async () => { + await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({ + bridgeUrl: 'https://prod.example.com/api/novu', + devBridgeUrl: 'https://tunnel.example.com', + devBridgeActive: true, + }); + + const res = await session.testAgent.get(`/v1/agents/${encodeURIComponent(identifier)}`); + + expect(res.status).to.equal(200); + expect(res.body.data.bridgeUrl).to.equal('https://prod.example.com/api/novu'); + expect(res.body.data.devBridgeUrl).to.equal('https://tunnel.example.com'); + expect(res.body.data.devBridgeActive).to.equal(true); + }); + + it('should deactivate devBridgeActive', async () => { + await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({ + devBridgeUrl: 'https://tunnel.example.com', + devBridgeActive: true, + }); + + const res = await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({ + devBridgeActive: false, + }); + + expect(res.status).to.equal(200); + expect(res.body.data.devBridgeActive).to.equal(false); + expect(res.body.data.devBridgeUrl).to.equal('https://tunnel.example.com'); + }); + }); + + describe('Production environment guard', () => { + let prodSession: UserSession; + let identifier: string; + + before(async () => { + prodSession = new UserSession(); + await prodSession.initialize(); + }); + + beforeEach(async () => { + identifier = `e2e-prodguard-${Date.now()}`; + + await prodSession.switchToDevEnvironment(); + await prodSession.testAgent.post('/v1/agents').send({ name: 'Guard Agent', identifier }); + }); + + afterEach(async () => { + await prodSession.switchToDevEnvironment(); + await prodSession.testAgent.delete(`/v1/agents/${encodeURIComponent(identifier)}`); + }); + + it('should reject devBridgeActive=true on production environment', async () => { + await prodSession.switchToProdEnvironment(); + + await prodSession.testAgent.post('/v1/agents').send({ name: 'Prod Agent', identifier: `${identifier}-prod` }); + + const res = await prodSession.testAgent.patch(`/v1/agents/${encodeURIComponent(`${identifier}-prod`)}`).send({ + devBridgeActive: true, + }); + + expect(res.status).to.equal(403); + + await prodSession.testAgent.delete(`/v1/agents/${encodeURIComponent(`${identifier}-prod`)}`); + }); + + it('should reject devBridgeUrl on production environment', async () => { + await prodSession.switchToProdEnvironment(); + + await prodSession.testAgent.post('/v1/agents').send({ name: 'Prod Agent 2', identifier: `${identifier}-prod2` }); + + const res = await prodSession.testAgent.patch(`/v1/agents/${encodeURIComponent(`${identifier}-prod2`)}`).send({ + devBridgeUrl: 'https://tunnel.example.com', + }); + + expect(res.status).to.equal(403); + + await prodSession.testAgent.delete(`/v1/agents/${encodeURIComponent(`${identifier}-prod2`)}`); + }); + + it('should allow bridgeUrl on production environment', async () => { + await prodSession.switchToProdEnvironment(); + + await prodSession.testAgent.post('/v1/agents').send({ name: 'Prod Agent 3', identifier: `${identifier}-prod3` }); + + const res = await prodSession.testAgent.patch(`/v1/agents/${encodeURIComponent(`${identifier}-prod3`)}`).send({ + bridgeUrl: 'https://prod.example.com/novu', + }); + + expect(res.status).to.equal(200); + expect(res.body.data.bridgeUrl).to.equal('https://prod.example.com/novu'); + + await prodSession.testAgent.delete(`/v1/agents/${encodeURIComponent(`${identifier}-prod3`)}`); + }); + }); }); diff --git a/apps/api/src/app/agents/e2e/mock-agent-handler.ts b/apps/api/src/app/agents/e2e/mock-agent-handler.ts index 9c17d1078a9..e69e981293c 100644 --- a/apps/api/src/app/agents/e2e/mock-agent-handler.ts +++ b/apps/api/src/app/agents/e2e/mock-agent-handler.ts @@ -128,18 +128,6 @@ const echoBot = agent('novu-agent', { await ctx.reply(`Echo: ${userText}`); }, - onReaction: async (ctx) => { - console.log('\n─────────────────────────────────────────'); - console.log(`[${ctx.event}] reaction: ${ctx.reaction?.emoji.name} (${ctx.reaction?.added ? 'added' : 'removed'})`); - console.log(`Reacted message: ${ctx.reaction?.message?.text ?? '(unavailable)'}`); - console.log('─────────────────────────────────────────'); - - const emoji = ctx.reaction?.emoji.name ?? 'unknown'; - const added = ctx.reaction?.added ?? false; - - await ctx.reply(`Got ${added ? '' : 'un'}reaction: :${emoji}:`); - }, - onAction: async (ctx) => { console.log('\n─────────────────────────────────────────'); console.log(`[${ctx.event}] action: ${ctx.action?.actionId} = ${ctx.action?.value ?? '(no value)'}`); diff --git a/apps/api/src/app/agents/mappers/agent-response.mapper.ts b/apps/api/src/app/agents/mappers/agent-response.mapper.ts index 221339dc875..2ef3d41d7cb 100644 --- a/apps/api/src/app/agents/mappers/agent-response.mapper.ts +++ b/apps/api/src/app/agents/mappers/agent-response.mapper.ts @@ -10,6 +10,9 @@ export function toAgentResponse(agent: AgentEntity): AgentResponseDto { description: agent.description, active: agent.active, behavior: agent.behavior, + bridgeUrl: agent.bridgeUrl, + devBridgeUrl: agent.devBridgeUrl, + devBridgeActive: agent.devBridgeActive, _environmentId: agent._environmentId, _organizationId: agent._organizationId, createdAt: agent.createdAt, diff --git a/apps/api/src/app/agents/services/agent-config-resolver.service.ts b/apps/api/src/app/agents/services/agent-config-resolver.service.ts index e1757c5c7b9..639d63c495b 100644 --- a/apps/api/src/app/agents/services/agent-config-resolver.service.ts +++ b/apps/api/src/app/agents/services/agent-config-resolver.service.ts @@ -23,6 +23,9 @@ export interface ResolvedAgentConfig { thinkingIndicatorEnabled: boolean; reactionOnMessageReceived: string | null; reactionOnResolved: string | null; + bridgeUrl?: string; + devBridgeUrl?: string; + devBridgeActive?: boolean; } const DEFAULT_REACTION_ON_MESSAGE = 'eyes'; @@ -135,6 +138,9 @@ export class AgentConfigResolver { DEFAULT_REACTION_ON_MESSAGE ), reactionOnResolved: resolveReaction(agent.behavior?.reactions?.onResolved, DEFAULT_REACTION_ON_RESOLVED), + bridgeUrl: agent.bridgeUrl, + devBridgeUrl: agent.devBridgeUrl, + devBridgeActive: agent.devBridgeActive, }; } } diff --git a/apps/api/src/app/agents/services/bridge-executor.service.ts b/apps/api/src/app/agents/services/bridge-executor.service.ts index b60255b7c47..d0ccdc7cfe1 100644 --- a/apps/api/src/app/agents/services/bridge-executor.service.ts +++ b/apps/api/src/app/agents/services/bridge-executor.service.ts @@ -10,7 +10,6 @@ import { ConversationActivitySenderTypeEnum, ConversationActivityTypeEnum, ConversationEntity, - EnvironmentRepository, SubscriberEntity, } from '@novu/dal'; import type { Message } from 'chat'; @@ -129,7 +128,6 @@ export class NoBridgeUrlError extends Error { @Injectable() export class BridgeExecutorService { constructor( - private readonly environmentRepository: EnvironmentRepository, private readonly getDecryptedSecretKey: GetDecryptedSecretKey, private readonly logger: PinoLogger ) {} @@ -140,12 +138,7 @@ export class BridgeExecutorService { try { const { config, event } = params; - const bridgeUrl = await this.resolveBridgeUrl( - config.environmentId, - config.organizationId, - agentIdentifier, - event - ); + const bridgeUrl = this.resolveBridgeUrl(config, agentIdentifier, event); if (!bridgeUrl) { throw new NoBridgeUrlError(agentIdentifier); } @@ -219,20 +212,21 @@ export class BridgeExecutorService { }); } - private async resolveBridgeUrl( - environmentId: string, - organizationId: string, + private resolveBridgeUrl( + config: ResolvedAgentConfig, agentIdentifier: string, event: AgentEventEnum - ): Promise { - const environment = await this.environmentRepository.findOne( - { _id: environmentId, _organizationId: organizationId }, - ['bridge'] - ); - const baseUrl = environment?.bridge?.url; + ): string | null { + let baseUrl: string | undefined; + + if (config.devBridgeActive && config.devBridgeUrl) { + baseUrl = config.devBridgeUrl; + } else if (config.bridgeUrl) { + baseUrl = config.bridgeUrl; + } if (!baseUrl) { - this.logger.warn(`[agent:${agentIdentifier}] No bridge URL configured on environment, skipping bridge call`); + this.logger.warn(`[agent:${agentIdentifier}] No bridge URL configured on agent, skipping bridge call`); return null; } diff --git a/apps/api/src/app/agents/usecases/update-agent/update-agent.command.ts b/apps/api/src/app/agents/usecases/update-agent/update-agent.command.ts index 255007dbc40..437be691973 100644 --- a/apps/api/src/app/agents/usecases/update-agent/update-agent.command.ts +++ b/apps/api/src/app/agents/usecases/update-agent/update-agent.command.ts @@ -1,5 +1,5 @@ import { Type } from 'class-transformer'; -import { IsBoolean, IsNotEmpty, IsOptional, IsString, ValidateIf, ValidateNested } from 'class-validator'; +import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUrl, ValidateIf, ValidateNested } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; import { AgentBehaviorDto } from '../../dtos/agent-behavior.dto'; @@ -25,4 +25,16 @@ export class UpdateAgentCommand extends EnvironmentWithUserCommand { @Type(() => AgentBehaviorDto) @IsOptional() behavior?: AgentBehaviorDto; + + @IsUrl({ require_tld: false }) + @IsOptional() + bridgeUrl?: string; + + @IsUrl({ require_tld: false }) + @IsOptional() + devBridgeUrl?: string; + + @ValidateIf((_, value) => value !== undefined) + @IsBoolean() + devBridgeActive?: boolean; } diff --git a/apps/api/src/app/agents/usecases/update-agent/update-agent.usecase.ts b/apps/api/src/app/agents/usecases/update-agent/update-agent.usecase.ts index 075675c52f5..fdb69f3cfdb 100644 --- a/apps/api/src/app/agents/usecases/update-agent/update-agent.usecase.ts +++ b/apps/api/src/app/agents/usecases/update-agent/update-agent.usecase.ts @@ -1,23 +1,39 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { AgentRepository } from '@novu/dal'; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { AgentRepository, EnvironmentRepository } from '@novu/dal'; +import { EnvironmentTypeEnum } from '@novu/shared'; import type { AgentResponseDto } from '../../dtos'; import { toAgentResponse } from '../../mappers/agent-response.mapper'; import { UpdateAgentCommand } from './update-agent.command'; @Injectable() export class UpdateAgent { - constructor(private readonly agentRepository: AgentRepository) {} + constructor( + private readonly agentRepository: AgentRepository, + private readonly environmentRepository: EnvironmentRepository + ) {} async execute(command: UpdateAgentCommand): Promise { - if ( - command.name === undefined && - command.description === undefined && - command.behavior === undefined && - command.active === undefined - ) { - throw new BadRequestException( - 'At least one of name, description, behavior, or active must be provided.' - ); + const hasBehaviorFields = + command.behavior?.thinkingIndicatorEnabled !== undefined || + command.behavior?.reactions?.onMessageReceived !== undefined || + command.behavior?.reactions?.onResolved !== undefined; + + const hasGeneralFields = + command.name !== undefined || + command.description !== undefined || + command.active !== undefined || + hasBehaviorFields; + const hasBridgeFields = + command.bridgeUrl !== undefined || + command.devBridgeUrl !== undefined || + command.devBridgeActive !== undefined; + + if (!hasGeneralFields && !hasBridgeFields) { + throw new BadRequestException('At least one field must be provided.'); + } + + if (command.devBridgeActive === true || (command.devBridgeUrl !== undefined && command.devBridgeUrl !== null)) { + await this.assertNotProductionEnvironment(command.environmentId, command.organizationId); } const existing = await this.agentRepository.findOne( @@ -47,21 +63,33 @@ export class UpdateAgent { $set.active = command.active; } - if (command.behavior !== undefined) { - if (command.behavior.thinkingIndicatorEnabled !== undefined) { - $set['behavior.thinkingIndicatorEnabled'] = command.behavior.thinkingIndicatorEnabled; + if (hasBehaviorFields) { + if (command.behavior!.thinkingIndicatorEnabled !== undefined) { + $set['behavior.thinkingIndicatorEnabled'] = command.behavior!.thinkingIndicatorEnabled; } - if (command.behavior.reactions !== undefined) { - if (command.behavior.reactions.onMessageReceived !== undefined) { - $set['behavior.reactions.onMessageReceived'] = command.behavior.reactions.onMessageReceived; + if (command.behavior!.reactions !== undefined) { + if (command.behavior!.reactions.onMessageReceived !== undefined) { + $set['behavior.reactions.onMessageReceived'] = command.behavior!.reactions.onMessageReceived; } - if (command.behavior.reactions.onResolved !== undefined) { - $set['behavior.reactions.onResolved'] = command.behavior.reactions.onResolved; + if (command.behavior!.reactions.onResolved !== undefined) { + $set['behavior.reactions.onResolved'] = command.behavior!.reactions.onResolved; } } } + if (command.bridgeUrl !== undefined) { + $set.bridgeUrl = command.bridgeUrl; + } + + if (command.devBridgeUrl !== undefined) { + $set.devBridgeUrl = command.devBridgeUrl; + } + + if (command.devBridgeActive !== undefined) { + $set.devBridgeActive = command.devBridgeActive; + } + await this.agentRepository.updateOne( { _id: existing._id, @@ -86,4 +114,15 @@ export class UpdateAgent { return toAgentResponse(updated); } + + private async assertNotProductionEnvironment(environmentId: string, organizationId: string): Promise { + const environment = await this.environmentRepository.findOne( + { _id: environmentId, _organizationId: organizationId }, + ['type', 'name'] + ); + + if (environment?.type === EnvironmentTypeEnum.PROD) { + throw new ForbiddenException('Dev bridge cannot be activated on production environments.'); + } + } } diff --git a/apps/dashboard/src/api/agents.ts b/apps/dashboard/src/api/agents.ts index 8c5ccc9a0c6..db528dd093a 100644 --- a/apps/dashboard/src/api/agents.ts +++ b/apps/dashboard/src/api/agents.ts @@ -40,6 +40,9 @@ export type AgentResponse = { identifier: string; description?: string; active: boolean; + bridgeUrl?: string; + devBridgeUrl?: string; + devBridgeActive?: boolean; _environmentId: string; _organizationId: string; createdAt: string; @@ -66,6 +69,9 @@ export type UpdateAgentBody = { name?: string; description?: string; active?: boolean; + bridgeUrl?: string; + devBridgeUrl?: string; + devBridgeActive?: boolean; }; export type ListAgentsParams = { diff --git a/apps/dashboard/src/components/agents/agent-details-header.tsx b/apps/dashboard/src/components/agents/agent-details-header.tsx index f809a65ec4b..0caa8d6b909 100644 --- a/apps/dashboard/src/components/agents/agent-details-header.tsx +++ b/apps/dashboard/src/components/agents/agent-details-header.tsx @@ -1,6 +1,7 @@ import { PermissionsEnum } from '@novu/shared'; import { RiMore2Fill, RiRobot2Line } from 'react-icons/ri'; import type { AgentResponse } from '@/api/agents'; +import { Badge } from '@/components/primitives/badge'; import { Button } from '@/components/primitives/button'; import { DropdownMenu, @@ -41,7 +42,14 @@ export function AgentDetailsHeader({ agent, isLoading, onRequestDelete }: AgentD
-

{agent.name}

+
+

{agent.name}

+ {agent.devBridgeActive ? ( + + DEV + + ) : null} +
{agent.identifier} diff --git a/apps/dashboard/src/components/agents/agent-sidebar-widget.tsx b/apps/dashboard/src/components/agents/agent-sidebar-widget.tsx index be036c8eefb..0621d96d586 100644 --- a/apps/dashboard/src/components/agents/agent-sidebar-widget.tsx +++ b/apps/dashboard/src/components/agents/agent-sidebar-widget.tsx @@ -12,6 +12,7 @@ import { Input } from '@/components/primitives/input'; import { showErrorToast, showSuccessToast } from '@/components/primitives/sonner-helpers'; import { Switch } from '@/components/primitives/switch'; import { Textarea } from '@/components/primitives/textarea'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; import { TimeDisplayHoverCard } from '@/components/time-display-hover-card'; import { requireEnvironment, useEnvironment } from '@/context/environment/hooks'; import { useHasPermission } from '@/hooks/use-has-permission'; @@ -44,6 +45,67 @@ function SidebarRow({ label, children, className }: { label: string; children: R ); } +function TruncatedUrl({ url }: { url: string }) { + + return ( + + + + + + {url} + + + ); +} + +type BridgeUrlSectionProps = { + agent: AgentResponse; + canWrite: boolean; + isUpdatePending: boolean; + onUpdate: (body: UpdateAgentBody) => Promise; +}; + +function BridgeUrlSection({ agent, canWrite, isUpdatePending, onUpdate }: BridgeUrlSectionProps) { + const isDevOverrideActive = Boolean(agent.devBridgeActive && agent.devBridgeUrl); + const activeBridgeUrl = isDevOverrideActive ? agent.devBridgeUrl : agent.bridgeUrl; + + return ( + <> + + {activeBridgeUrl ? ( +
+ {isDevOverrideActive ? ( + + DEV + + ) : null} + +
+ ) : ( + Not configured + )} +
+ {agent.devBridgeUrl ? ( + + { + void onUpdate({ devBridgeActive: checked }); + }} + /> + + ) : null} + + ); +} + export function AgentSidebarWidget({ agent }: AgentSidebarWidgetProps) { const queryClient = useQueryClient(); const { currentEnvironment } = useEnvironment(); @@ -286,6 +348,8 @@ export function AgentSidebarWidget({ agent }: AgentSidebarWidgetProps) { + +