Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type IconName } from '@fortawesome/fontawesome-common-types'
import { Outlet, createFileRoute, useMatchRoute } from '@tanstack/react-router'
import { Link as RouterLink } from '@tanstack/react-router'
import posthog from 'posthog-js'
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useEffect, useMemo } from 'react'
import {
ClusterAvatar,
Expand All @@ -22,7 +23,7 @@ import {
} from '@qovery/domains/environments/feature'
import { useEnvironmentsOverview } from '@qovery/domains/projects/feature'
import { isArgoCd, isEditableService } from '@qovery/domains/services/data-access'
import { ArgoCdServiceList, useServices } from '@qovery/domains/services/feature'
import { AgenticWorkflowServiceList, ArgoCdServiceList, useServices } from '@qovery/domains/services/feature'
import { Heading, Icon, Link, Navbar, Section, Tooltip } from '@qovery/shared/ui'

export const Route = createFileRoute(
Expand All @@ -44,6 +45,7 @@ function RouteComponent() {
const { data: deploymentStatus } = useDeploymentStatus({ environmentId })
const { data: cluster } = useCluster({ organizationId, clusterId: environment?.cluster_id, suspense: true })
const { data: services = [] } = useServices({ environmentId, suspense: true })
const isAgenticWorkflowEnabled = Boolean(useFeatureFlagEnabled('argentic-workflow'))

useClusterRunningStatusSocket({
organizationId,
Expand Down Expand Up @@ -195,6 +197,9 @@ function RouteComponent() {
)}
</div>
{shouldDisplayArgoCdServicesBelowQovery && <ArgoCdServiceList environment={environment} />}
{isServicesListTab && isAgenticWorkflowEnabled && (
<AgenticWorkflowServiceList environmentId={environmentId} />
)}
</div>
</Section>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createQueryKeys, type inferQueryKeys } from '@lukemorales/query-key-factory'
import {
type AgenticWorkflowResponse,
AgenticWorkflowsApi,
ApplicationActionsApi,
type ApplicationAdvancedSettings,
ApplicationConfigurationApi,
Expand Down Expand Up @@ -104,6 +106,7 @@ const helmsApi = new HelmsApi()
const terraformsApi = new TerraformsApi()
const blueprintApi = new BlueprintMainCallsApi()
const blueprintCatalogApi = new BlueprintCatalogApi()
const agenticWorkflowsApi = new AgenticWorkflowsApi()

const applicationMainCallsApi = new ApplicationMainCallsApi()
const containerMainCallsApi = new ContainerMainCallsApi()
Expand Down Expand Up @@ -238,7 +241,7 @@ export function isArgoCd(service?: AnyService): service is ArgoCd {
}

export function isEditableService(service: AnyService): service is EditableService {
return !isArgoCd(service)
return !isArgoCd(service) && (service.service_type as string) !== 'AGENTIC_WORKFLOW'
}

export function isEditableServiceType(serviceType?: ServiceType): serviceType is EditableServiceType {
Expand Down Expand Up @@ -365,6 +368,13 @@ export const services = createQueryKeys('services', {
)
},
}),
listAgenticWorkflows: (environmentId: string) => ({
queryKey: [environmentId],
async queryFn(): Promise<AgenticWorkflowResponse[]> {
const response = await agenticWorkflowsApi.listAgenticWorkflows(environmentId)
return response.data.results ?? []
},
}),
argocdManifest: (serviceId: string) => ({
queryKey: [serviceId],
async queryFn() {
Expand Down
2 changes: 2 additions & 0 deletions libs/domains/services/feature/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export * from './lib/hooks/use-recent-services/use-recent-services'
export * from './lib/hooks/use-favorite-services/use-favorite-services'
export * from './lib/service-actions/service-actions'
export * from './lib/argocd-service-list/argocd-service-list'
export * from './lib/agentic-workflow-service-list/agentic-workflow-service-list'
export * from './lib/hooks/use-agentic-workflow-services/use-agentic-workflow-services'
export * from './lib/argocd-manifest/argocd-manifest'
export * from './lib/service-deployment-status-label/service-deployment-status-label'
export * from './lib/service-overview/service-header/service-header'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import { AgenticWorkflowServiceList } from './agentic-workflow-service-list'

const mockUseAgenticWorkflowServices = jest.fn()

jest.mock('../hooks/use-agentic-workflow-services/use-agentic-workflow-services', () => ({
useAgenticWorkflowServices: () => mockUseAgenticWorkflowServices(),
}))

describe('AgenticWorkflowServiceList', () => {
it('should not render an empty section', () => {
mockUseAgenticWorkflowServices.mockReturnValue({ data: [] })
const { container } = renderWithProviders(<AgenticWorkflowServiceList environmentId="environment-1" />)

expect(container).toBeEmptyDOMElement()
})

it('should render agentic workflows in their own section', () => {
mockUseAgenticWorkflowServices.mockReturnValue({
data: [
{ id: 'workflow-1', name: 'Review pull requests', enabled: true, model: { type: 'CLAUDE' } },
{ id: 'workflow-2', name: 'Triage incidents', enabled: false, model: { type: 'OPENAI' } },
],
})
renderWithProviders(<AgenticWorkflowServiceList environmentId="environment-1" />)

expect(screen.getByRole('heading', { name: 'Agentic workflows' })).toBeInTheDocument()
expect(screen.getByText('Review pull requests')).toBeInTheDocument()
expect(screen.getByText('Triage incidents')).toBeInTheDocument()
expect(screen.getByText('1 enabled')).toBeInTheDocument()
expect(screen.getByText('CLAUDE')).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { type AgenticWorkflowResponse } from 'qovery-typescript-axios'
import { Badge, Heading, Icon, Section, TablePrimitives, Tooltip } from '@qovery/shared/ui'
import { useAgenticWorkflowServices } from '../hooks/use-agentic-workflow-services/use-agentic-workflow-services'

const { Table } = TablePrimitives

const tableGridLayoutClassName = 'grid w-full grid-cols-[minmax(350px,1fr)_minmax(180px,260px)_140px]'

export interface AgenticWorkflowServiceListProps {
environmentId: string
}

export function AgenticWorkflowServiceList({ environmentId }: AgenticWorkflowServiceListProps) {
const { data: services = [] } = useAgenticWorkflowServices({ environmentId, suspense: true })

if (services.length === 0) return null

const enabledServicesCount = services.filter(({ enabled }) => enabled).length

return (
<Section className="flex flex-col gap-3.5">
<div className="flex flex-col gap-1">
<Heading level={3} className="font-medium text-neutral-subtle">
Agentic workflows
</Heading>
<p className="text-sm text-neutral-subtle">AI workflows triggered through webhooks and connected services.</p>
</div>

<div className="flex flex-col overflow-hidden rounded-lg border border-neutral">
<div className="flex gap-1.5 bg-surface-neutral px-4 py-3">
<Badge variant="surface" radius="full" color="green" className="text-ssm font-medium">
{enabledServicesCount} enabled
</Badge>
</div>
<Table.Root
containerClassName="rounded-none border-x-0 border-b-0 border-t"
className="w-full min-w-[700px] overflow-x-scroll text-xs xl:overflow-auto"
>
<Table.Header className="border-neutral">
<Table.Row className={`h-9 w-full ${tableGridLayoutClassName}`}>
<Table.ColumnHeaderCell className="flex h-full items-center border-r border-neutral text-neutral-subtle">
Service
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell className="flex h-full items-center border-r border-neutral text-neutral-subtle">
Model
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell className="flex h-full items-center text-neutral-subtle">
Status
</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{services.map((service: AgenticWorkflowResponse) => (
<Table.Row key={service.id} className={`h-[60px] w-full ${tableGridLayoutClassName}`}>
<Table.Cell className="flex h-full min-w-0 items-center border-r border-neutral">
<div className="flex min-w-0 items-center gap-3 text-sm font-medium">
<Icon iconName="brain-circuit" className="shrink-0 text-neutral" />
<Tooltip content={service.name}>
<span className="truncate text-neutral">{service.name}</span>
</Tooltip>
</div>
</Table.Cell>
<Table.Cell className="flex h-full items-center border-r border-neutral text-sm text-neutral">
{service.model?.type ?? '-'}
</Table.Cell>
<Table.Cell className="flex h-full items-center">
<Badge variant="surface" color={service.enabled ? 'green' : 'neutral'}>
{service.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</div>
</Section>
)
}

export default AgenticWorkflowServiceList
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query'
import { queries } from '@qovery/state/util-queries'

export interface UseAgenticWorkflowServicesProps {
environmentId: string
suspense?: boolean
}

export function useAgenticWorkflowServices({ environmentId, suspense = false }: UseAgenticWorkflowServicesProps) {
return useQuery({
...queries.services.listAgenticWorkflows(environmentId),
suspense,
})
}

export default useAgenticWorkflowServices
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,24 @@ describe('ServiceList', () => {
expect(screen.queryByText('ARGOCD APP')).not.toBeInTheDocument()
})

it('should not pass agentic workflows to the standard service table', () => {
mockServicesData = [
{
id: 'agentic-workflow-id',
name: 'Review pull requests',
service_type: 'AGENTIC_WORKFLOW',
serviceType: 'AGENTIC_WORKFLOW',
enabled: true,
model: { type: 'CLAUDE' },
},
]

renderWithProviders(<ServiceList {...serviceListProps} />)

expect(screen.getByText('No service found')).toBeInTheDocument()
expect(screen.queryByText('Review pull requests')).not.toBeInTheDocument()
})

it('should navigate to service on row click', async () => {
const { userEvent } = renderWithProviders(<ServiceList {...serviceListProps} />)
const rows = screen.getAllByRole('row')
Expand Down
Loading