Skip to content
Open
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 @@ -198,7 +198,7 @@ function RouteComponent() {
</div>
{shouldDisplayArgoCdServicesBelowQovery && <ArgoCdServiceList environment={environment} />}
{isServicesListTab && isAgenticWorkflowEnabled && (
<AgenticWorkflowServiceList environmentId={environmentId} />
<AgenticWorkflowServiceList environment={environment} />
)}
</div>
</Section>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useEnvironment } from '@qovery/domains/environments/feature'
import { EnableObservabilityModal } from '@qovery/domains/observability/feature'
import { JobStatusesCallout } from '@qovery/domains/service-job/feature'
import { TerraformResourcesSection } from '@qovery/domains/service-terraform/feature'
import { isAgenticWorkflow } from '@qovery/domains/services/data-access'
import { ObservabilityCallout, ServiceOverview, useService } from '@qovery/domains/services/feature'
import { MetricsWebSocketListener } from '@qovery/shared/util-web-sockets'

Expand Down Expand Up @@ -53,7 +54,7 @@ function RouteComponent() {
environment && <JobStatusesCallout environmentId={environment.id} serviceId={service.id} />
}
/>
{environment && service?.serviceType && (
{environment && service?.serviceType && !isAgenticWorkflow(service) && (
<WebSocketListenerMemo
organizationId={environment.organization.id}
clusterId={environment.cluster_id}
Expand Down
10 changes: 8 additions & 2 deletions apps/console/src/routes/_authenticated/organization/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ import { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useClusters } from '@qovery/domains/clusters/feature'
import { useEnvironment } from '@qovery/domains/environments/feature'
import { useProject } from '@qovery/domains/projects/feature'
import { type AnyService, isArgoCd, isEditableService, isManagedDatabase } from '@qovery/domains/services/data-access'
import { type AnyService, isAgenticWorkflow, isArgoCd, isManagedDatabase } from '@qovery/domains/services/data-access'
import { useRecentServices, useServiceSummary } from '@qovery/domains/services/feature'
import { AssistantPanelOutlet, AssistantProvider } from '@qovery/shared/assistant/feature'
import { DevopsCopilotContext } from '@qovery/shared/devops-copilot/context'
import { DevopsCopilotTrigger } from '@qovery/shared/devops-copilot/feature'
import { ServiceTypeEnum } from '@qovery/shared/enums'
import { ErrorBoundary, Icon, Link, LoaderSpinner, Navbar } from '@qovery/shared/ui'
import { queries } from '@qovery/state/util-queries'
import Header from '../../../app/components/header/header'
Expand Down Expand Up @@ -218,6 +219,7 @@ const SERVICE_TABS: NavigationTab[] = [
]

const ARGOCD_SERVICE_TAB_IDS = ['overview', 'service-logs', 'cloud-shell', 'manifest']
const AGENTIC_WORKFLOW_SERVICE_TAB_IDS = ['overview', 'service-logs']

function hasServiceMonitoringTab(service?: AnyService, cluster?: Cluster) {
if (!service) return false
Expand Down Expand Up @@ -256,6 +258,10 @@ function getServiceTabs(service?: AnyService, cluster?: Cluster) {
return SERVICE_TABS.filter((tab) => ARGOCD_SERVICE_TAB_IDS.includes(tab.id))
}

if (isAgenticWorkflow(service)) {
return SERVICE_TABS.filter((tab) => AGENTIC_WORKFLOW_SERVICE_TAB_IDS.includes(tab.id))
}

const isDatabase = service?.serviceType === 'DATABASE'
const isManagedDatabaseService = isManagedDatabase(service)
const hasMonitoring = hasServiceMonitoringTab(service, cluster)
Expand Down Expand Up @@ -564,7 +570,7 @@ function OrganizationRoute() {
name: service.name,
description: 'description' in service ? service.description || '' : '',
icon_uri: service.icon_uri,
service_type: service.service_type,
service_type: isAgenticWorkflow(service) ? ServiceTypeEnum.ARGOCD_APP : service.service_type,
project_id: project.id,
project_name: project.name,
environment_id: environment.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,19 +379,6 @@ export const services = createQueryKeys('services', {
)
},
}),
listAgenticWorkflows: (environmentId: string) => ({
queryKey: [environmentId],
async queryFn(): Promise<AgenticWorkflow[]> {
const response = await agenticWorkflowsApi.listAgenticWorkflows(environmentId)
return (
response.data.results?.map((service) => ({
...service,
serviceType: 'AGENTIC_WORKFLOW',
icon_uri: 'app://qovery-console/agentic-workflow',
})) ?? []
)
},
}),
argocdManifest: (serviceId: string) => ({
queryKey: [serviceId],
async queryFn() {
Expand Down
1 change: 0 additions & 1 deletion libs/domains/services/feature/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ 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
@@ -1,33 +1,87 @@
import { type Environment } from 'qovery-typescript-axios'
import { type ReactNode } from 'react'
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import { AgenticWorkflowServiceList } from './agentic-workflow-service-list'

const mockUseAgenticWorkflowServices = jest.fn()
const mockUseServices = jest.fn()
const mockNavigate = jest.fn()
const environment = {
id: 'environment-1',
organization: { id: 'organization-1' },
project: { id: 'project-1' },
} as Environment

jest.mock('../hooks/use-agentic-workflow-services/use-agentic-workflow-services', () => ({
useAgenticWorkflowServices: () => mockUseAgenticWorkflowServices(),
jest.mock('@tanstack/react-router', () => ({
useNavigate: () => mockNavigate,
useParams: () => ({ organizationId: 'organization-1', projectId: 'project-1' }),
Link: ({ children, params, to, ...props }: { children: ReactNode; params: Record<string, string>; to: string }) => (
<a {...props} href={Object.entries(params).reduce((path, [key, value]) => path.replace(`$${key}`, value), to)}>
{children}
</a>
),
}))

jest.mock('../hooks/use-services/use-services', () => ({
useServices: () => mockUseServices(),
}))

jest.mock('../service-actions/service-actions', () => ({
ServiceActions: ({ serviceId }: { serviceId: string }) => <button type="button">Actions for {serviceId}</button>,
}))

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

expect(container).toBeEmptyDOMElement()
})

it('should render agentic workflows in their own section', () => {
mockUseAgenticWorkflowServices.mockReturnValue({
mockUseServices.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' } },
{
id: 'workflow-1',
name: 'Review pull requests',
enabled: true,
model: { type: 'CLAUDE' },
service_type: 'AGENTIC_WORKFLOW',
serviceType: 'AGENTIC_WORKFLOW',
project_repositories: [
{ url: 'https://github.com/qovery/console', branch: 'staging', git_token_id: 'token-1' },
{ url: 'https://github.com/qovery/docs', branch: 'main', git_token_id: 'token-1' },
],
},
{
id: 'workflow-2',
name: 'Triage incidents',
enabled: false,
model: { type: 'BEDROCK' },
service_type: 'AGENTIC_WORKFLOW',
serviceType: 'AGENTIC_WORKFLOW',
project_repositories: [],
},
{ id: 'application-1', name: 'API', service_type: 'APPLICATION', serviceType: 'APPLICATION' },
],
})
renderWithProviders(<AgenticWorkflowServiceList environmentId="environment-1" />)
renderWithProviders(<AgenticWorkflowServiceList environment={environment} />)

expect(screen.getByRole('heading', { name: 'Agentic workflows' })).toBeInTheDocument()
expect(screen.getByText('Review pull requests')).toBeInTheDocument()
expect(screen.getByText('Triage incidents')).toBeInTheDocument()
expect(screen.getByText('Enabled')).toBeInTheDocument()
expect(screen.getByText('Disabled')).toBeInTheDocument()
expect(screen.getByText('1 enabled')).toBeInTheDocument()
expect(screen.getByText('CLAUDE')).toBeInTheDocument()
expect(screen.getByText('Last operation')).toBeInTheDocument()
expect(screen.getByText('Model')).toBeInTheDocument()
expect(screen.getByText('Claude')).toBeInTheDocument()
expect(screen.getByText('Bedrock')).toBeInTheDocument()
expect(screen.queryByText('Git repositories')).not.toBeInTheDocument()
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Actions for workflow-1' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Review pull requests' })).toHaveAttribute(
'href',
expect.stringContaining('/service/workflow-1/overview')
)
})
})
Original file line number Diff line number Diff line change
@@ -1,22 +1,64 @@
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'
import { useNavigate } from '@tanstack/react-router'
import { type Environment } from 'qovery-typescript-axios'
import { type KeyboardEvent, type MouseEvent } from 'react'
import { match } from 'ts-pattern'
import { type AgenticWorkflow, isAgenticWorkflow } from '@qovery/domains/services/data-access'
import { IconEnum } from '@qovery/shared/enums'
import { Badge, Heading, Icon, Section, TablePrimitives } from '@qovery/shared/ui'
import { useServices } from '../hooks/use-services/use-services'
import { ServiceActions } from '../service-actions/service-actions'
import { ServiceLastDeploymentCell, ServiceNameCell } from '../service-list/service-list-cells'
import { ServiceStateChip } from '../service-state-chip/service-state-chip'

const { Table } = TablePrimitives

const tableGridLayoutClassName = 'grid w-full grid-cols-[minmax(350px,1fr)_minmax(180px,260px)_140px]'
const tableGridLayoutClassName =
'grid w-full grid-cols-[minmax(280px,1.1fr)_minmax(300px,1fr)_minmax(200px,0.7fr)_130px]'

export interface AgenticWorkflowServiceListProps {
environmentId: string
environment: Environment
}

export function AgenticWorkflowServiceList({ environmentId }: AgenticWorkflowServiceListProps) {
const { data: services = [] } = useAgenticWorkflowServices({ environmentId, suspense: true })
function ModelCell({ service }: { service: AgenticWorkflow }) {
return match(service.model?.type)
.with('CLAUDE', () => (
<span className="flex items-center gap-2 text-sm text-neutral">
<img src="/assets/ai-tools/claude.svg" alt="" aria-hidden="true" className="h-5 w-5" />
Claude
</span>
))
.with('BEDROCK', () => (
<span className="flex items-center gap-2 text-sm text-neutral">
<Icon name={IconEnum.AWS_GRAY} className="h-5 w-5" />
Bedrock
</span>
))
.otherwise((model) => <span className="text-sm text-neutral-subtle">{model ?? 'Not configured'}</span>)
}

export function AgenticWorkflowServiceList({ environment }: AgenticWorkflowServiceListProps) {
const environmentId = environment.id
const organizationId = environment.organization.id
const projectId = environment.project.id
const navigate = useNavigate()
const { data: allServices = [] } = useServices({ environmentId, suspense: true })
const services = allServices.filter(isAgenticWorkflow) as AgenticWorkflow[]

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

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

const handleNavigateToService = (serviceId: string) => {
navigate({
to: '/organization/$organizationId/project/$projectId/environment/$environmentId/service/$serviceId/overview',
params: { organizationId, projectId, environmentId, serviceId },
})
}

const stopRowNavigation = (event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => {
event.stopPropagation()
}

return (
<Section className="flex flex-col gap-3.5">
<div className="flex flex-col gap-1">
Expand All @@ -34,39 +76,51 @@ export function AgenticWorkflowServiceList({ environmentId }: AgenticWorkflowSer
</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"
className="w-full min-w-[1080px] 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">
Last operation
</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
Actions
</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 name="AGENTIC_WORKFLOW" width={20} height={20} />
<Tooltip content={service.name}>
<span className="truncate text-neutral">{service.name}</span>
</Tooltip>
{services.map((service) => (
<Table.Row
key={service.id}
className={`h-[60px] w-full cursor-pointer hover:bg-surface-neutral-subtle ${tableGridLayoutClassName}`}
tabIndex={0}
onClick={() => handleNavigateToService(service.id)}
onKeyDown={(event) => {
if (event.key === 'Enter') handleNavigateToService(service.id)
}}
>
<Table.Cell className="flex h-full min-w-0 items-center gap-3 border-r border-neutral">
<div className="min-w-0 flex-1">
<ServiceNameCell service={service} environment={environment} />
</div>
<ServiceStateChip mode="running" environmentId={environmentId} serviceId={service.id} />
</Table.Cell>
<Table.Cell className="flex h-full items-center border-r border-neutral text-sm text-neutral">
{service.model?.type ?? '-'}
<Table.Cell className="flex h-full items-center border-r border-neutral">
<ServiceLastDeploymentCell service={service} environment={environment} />
</Table.Cell>
<Table.Cell className="flex h-full min-w-0 items-center border-r border-neutral">
<ModelCell service={service} />
</Table.Cell>
<Table.Cell className="flex h-full items-center">
<Badge variant="surface" color={service.enabled ? 'green' : 'neutral'}>
{service.enabled ? 'Enabled' : 'Disabled'}
</Badge>
<div onClick={stopRowNavigation} onKeyDown={stopRowNavigation}>
<ServiceActions serviceId={service.id} environment={environment} />
</div>
</Table.Cell>
</Table.Row>
))}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { queries } from '@qovery/state/util-queries'
export interface UseRunningStatusProps {
environmentId?: string
serviceId?: string
enabled?: boolean
suspense?: boolean
}

export function useRunningStatus({ environmentId, serviceId, suspense }: UseRunningStatusProps) {
export function useRunningStatus({ environmentId, serviceId, enabled = true, suspense }: UseRunningStatusProps) {
return useQuery({
// eslint-disable-next-line @typescript-eslint/no-extra-non-null-assertion
...queries.services.runningStatus(environmentId!!, serviceId!!),
enabled: Boolean(environmentId) && Boolean(serviceId),
...queries.services.runningStatus(environmentId ?? '', serviceId ?? ''),
enabled: enabled && Boolean(environmentId) && Boolean(serviceId),
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
Expand Down
Loading
Loading