+
+ children &&
openModal({
- content: ,
+ content: children,
options: {
width: 680,
},
@@ -164,81 +150,10 @@ function ObservabilityCallout() {
)
}
-
-export function PageGeneral({ serviceId, environmentId, service, hasNoMetrics }: PageGeneralProps) {
- const [activeTab, setActiveTab] = useState('variables')
- const isKedaFeatureEnabled = useFeatureFlagVariantKey('keda')
-
- const isLifecycleJob = useMemo(() => service?.serviceType === 'JOB' && service.job_type === 'LIFECYCLE', [service])
- const isTerraformService = useMemo(() => service?.serviceType === 'TERRAFORM', [service])
- const isCronJob = useMemo(() => service?.serviceType === 'JOB' && service.job_type === 'CRON', [service])
- const isKedaAutoscaling = useMemo(
- () =>
- isKedaFeatureEnabled &&
- (service?.serviceType === 'APPLICATION' || service?.serviceType === 'CONTAINER') &&
- service.autoscaling?.mode === 'KEDA',
- [service, isKedaFeatureEnabled]
- )
-
- return (
-
-
- {hasNoMetrics &&
}
-
- {!isTerraformService && (
-
- {isCronJob && (
-
-
-
- The number of past Completed or Failed job execution retained in the history and their TTL can be
- customized in the advanced settings.
-
-
- See documentation
-
-
- )}
-
- )}
- {isKedaAutoscaling &&
}
- {isLifecycleJob &&
}
- {isTerraformService && (
-
-
- Output Variables
- Infrastructure Resources
-
-
-
-
-
-
-
-
- )}
-
-
-
- )
-}
-
-export default PageGeneral
diff --git a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.spec.tsx b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.spec.tsx
new file mode 100644
index 00000000000..69657a0f035
--- /dev/null
+++ b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.spec.tsx
@@ -0,0 +1,294 @@
+import { type Environment } from 'qovery-typescript-axios'
+import type { ReactNode } from 'react'
+import { type AnyService } from '@qovery/domains/services/data-access'
+import { ToastEnum, toast } from '@qovery/shared/ui'
+import { renderWithProviders, screen } from '@qovery/shared/util-tests'
+import { ServiceHeader } from './service-header'
+
+const mockCopyToClipboard = jest.fn()
+const mockGetDatabaseConnectionUri = jest.fn(() => 'postgres://copied-uri')
+const services = {
+ 'application-mock': {
+ id: 'ebb84aa8-91c2-40fb-916d-3a158db354b7',
+ serviceType: 'APPLICATION',
+ name: 'console',
+ description: 'React Application the Qovery Console',
+ icon_uri: null,
+ environment: {
+ id: '28c47145-c8e7-4b9d-8d9e-c65c95b48425',
+ },
+ git_repository: {
+ provider: 'GITHUB',
+ url: 'https://github.com/Qovery/console.git',
+ name: 'Qovery/console',
+ branch: 'staging',
+ },
+ auto_deploy: true,
+ },
+ 'database-mock': {
+ id: 'ee3523e9-c81d-42ac-9d0c-f7bc09d5d28c',
+ serviceType: 'DATABASE',
+ name: 'containered-posgresSQL-clone',
+ description: '',
+ icon_uri: null,
+ environment: {
+ id: '0cd5d05e-0839-48ff-be67-ca3f4fcf8250',
+ },
+ type: 'POSTGRESQL',
+ version: '15',
+ mode: 'CONTAINER',
+ accessibility: 'PRIVATE',
+ },
+ 'job-mock': {
+ id: 'c070ebf8-5b82-4d94-8c4d-0c6b86d7c003',
+ serviceType: 'JOB',
+ job_type: 'LIFECYCLE',
+ name: 'test_lifecycle',
+ description: '',
+ icon_uri: null,
+ environment: {
+ id: '7aaa3a79-0afa-4d5e-b898-c2bf6f33a01a',
+ },
+ source: {},
+ auto_deploy: false,
+ },
+}
+
+jest.mock('@tanstack/react-router', () => ({
+ ...jest.requireActual('@tanstack/react-router'),
+ useParams: () => ({ organizationId: '', projectId: '' }),
+ Link: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) =>
{children},
+}))
+
+jest.mock('@qovery/shared/ui', () => ({
+ ...jest.requireActual('@qovery/shared/ui'),
+ toast: jest.fn(),
+ Heading: ({ children }: { children?: ReactNode }) =>
{children}
,
+}))
+
+jest.mock('@qovery/shared/util-hooks', () => ({
+ ...jest.requireActual('@qovery/shared/util-hooks'),
+ useCopyToClipboard: () => [undefined, mockCopyToClipboard],
+}))
+
+jest.mock('../../hooks/use-service/use-service', () => ({
+ useService: ({
+ serviceId,
+ }: {
+ environmentId: string
+ serviceId: 'application-mock' | 'database-mock' | 'job-mock'
+ }) => {
+ const mocks = {
+ 'application-mock': {
+ id: 'ebb84aa8-91c2-40fb-916d-3a158db354b7',
+ serviceType: 'APPLICATION',
+ created_at: '2023-04-12T08:48:51.801049Z',
+ updated_at: '2023-09-28T06:48:09.079032Z',
+ environment: {
+ id: '28c47145-c8e7-4b9d-8d9e-c65c95b48425',
+ },
+ auto_preview: true,
+ maximum_cpu: 3560,
+ maximum_memory: 15411,
+ name: 'console',
+ description: 'React Application the Qovery Console',
+ build_mode: 'DOCKER',
+ dockerfile_path: 'Dockerfile',
+ arguments: [],
+ entrypoint: '',
+ cpu: 50,
+ memory: 50,
+ min_running_instances: 1,
+ max_running_instances: 2,
+ storage: [],
+ ports: [
+ {
+ internal_port: 80,
+ external_port: 443,
+ publicly_accessible: true,
+ is_default: true,
+ protocol: 'HTTP',
+ name: 'p80',
+ id: '6c9052e8-ebda-439d-bcb2-3b0298d4b51a',
+ },
+ ],
+ healthchecks: {
+ readiness_probe: null,
+ liveness_probe: null,
+ },
+ git_repository: {
+ has_access: true,
+ deployed_commit_id: 'ddd1cee35762e9b4bb95633c22193393ca3bb384',
+ deployed_commit_date: '2023-09-28T06:42:35.688665Z',
+ deployed_commit_contributor: 'TAGS_NOT_IMPLEMENTED',
+ deployed_commit_tag: 'TAGS_NOT_IMPLEMENTED',
+ provider: 'GITHUB',
+ owner: 'acarranoqovery',
+ url: 'https://github.com/Qovery/console.git',
+ name: 'Qovery/console',
+ branch: 'staging',
+ root_path: '/',
+ },
+ auto_deploy: true,
+ },
+ 'database-mock': {
+ id: 'ee3523e9-c81d-42ac-9d0c-f7bc09d5d28c',
+ serviceType: 'DATABASE',
+ created_at: '2023-07-28T14:50:09.325974Z',
+ updated_at: '2023-07-28T14:50:09.325976Z',
+ environment: {
+ id: '0cd5d05e-0839-48ff-be67-ca3f4fcf8250',
+ },
+ name: 'containered-posgresSQL-clone',
+ description: '',
+ type: 'POSTGRESQL',
+ version: '15',
+ mode: 'CONTAINER',
+ disk_encrypted: false,
+ accessibility: 'PRIVATE',
+ host: 'zee3523e9-postgresql.zc531a994.rustrocks.cloud',
+ port: 5432,
+ cpu: 500,
+ memory: 512,
+ storage: 10,
+ maximum_cpu: 4000,
+ maximum_memory: 16384,
+ instance_type: null,
+ },
+ 'job-mock': {
+ id: 'c070ebf8-5b82-4d94-8c4d-0c6b86d7c003',
+ serviceType: 'JOB',
+ created_at: '2023-09-12T10:08:39.122972Z',
+ updated_at: '2023-09-27T12:17:25.956823Z',
+ environment: {
+ id: '7aaa3a79-0afa-4d5e-b898-c2bf6f33a01a',
+ },
+ name: 'test_lifecycle',
+ description: '',
+ auto_preview: true,
+ cpu: 50,
+ memory: 51,
+ max_nb_restart: 0,
+ max_duration_seconds: 300,
+ port: null,
+ source: {
+ docker: {
+ git_repository: {
+ has_access: true,
+ deployed_commit_id: 'a82fbbb9ff34784d1b77bebede6c5ac909d3a865',
+ deployed_commit_date: '2023-09-22T08:26:53.583504Z',
+ deployed_commit_contributor: 'TAGS_NOT_IMPLEMENTED',
+ deployed_commit_tag: 'TAGS_NOT_IMPLEMENTED',
+ provider: 'GITHUB',
+ owner: 'acarranoqovery',
+ url: 'https://github.com/acarranoqovery/test_script.git',
+ name: 'acarranoqovery/test_script',
+ branch: 'main',
+ root_path: '/',
+ },
+ dockerfile_path: 'Dockerfile',
+ },
+ },
+ schedule: {
+ on_start: {
+ arguments: ['job.py'],
+ },
+ },
+ maximum_cpu: 16000,
+ maximum_memory: 16384,
+ auto_deploy: false,
+ healthchecks: {
+ readiness_probe: null,
+ liveness_probe: null,
+ },
+ },
+ }
+ return {
+ data: mocks[serviceId],
+ isLoading: false,
+ error: null,
+ }
+ },
+}))
+
+jest.mock('../../hooks/use-master-credentials/use-master-credentials', () => ({
+ useMasterCredentials: () => ({
+ data: { login: 'admin', password: 'password' },
+ }),
+}))
+
+jest.mock('../../service-access-modal/service-access-modal', () => ({
+ getDatabaseConnectionUri: () => mockGetDatabaseConnectionUri(),
+}))
+
+jest.mock('../../service-action-toolbar/service-action-toolbar', () => ({
+ ServiceActionToolbar: () =>
service-action-toolbar
,
+}))
+
+jest.mock('../../service-avatar/service-avatar', () => ({
+ ServiceAvatar: () =>
service-avatar
,
+}))
+
+jest.mock('../../service-state-chip/service-state-chip', () => ({
+ ServiceStateChip: () =>
service-state-chip
,
+}))
+
+jest.mock('../../auto-deploy-badge/auto-deploy-badge', () => ({
+ __esModule: true,
+ default: () =>
auto-deploy-badge
,
+}))
+
+jest.mock('../../service-links-popover/service-links-popover', () => ({
+ ServiceLinksPopover: ({ children }: { children?: ReactNode }) =>
{children}
,
+}))
+
+describe('ServiceHeader', () => {
+ const environment = {
+ id: 'environment-id',
+ cluster_name: 'my-cluster',
+ cloud_provider: { provider: 'AWS' },
+ } as Environment
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockGetDatabaseConnectionUri.mockReturnValue('postgres://copied-uri')
+ })
+
+ const renderServiceHeader = (serviceId: 'application-mock' | 'database-mock' | 'job-mock') =>
+ renderWithProviders(
+
+ )
+
+ it('renders application details and git metadata', () => {
+ renderServiceHeader('application-mock')
+
+ expect(screen.getByRole('heading', { name: 'console' })).toBeInTheDocument()
+ expect(screen.getByText('my-cluster')).toBeInTheDocument()
+ expect(screen.getByText('React Application the Qovery Console')).toBeInTheDocument()
+ expect(screen.getByText('GitHub')).toBeInTheDocument()
+ expect(screen.getByText('Qovery/console')).toBeInTheDocument()
+ expect(screen.getByText('staging')).toBeInTheDocument()
+ expect(screen.getByText('auto-deploy-badge')).toBeInTheDocument()
+ })
+
+ it('renders database badges and copies the connection URI', async () => {
+ const { userEvent } = renderServiceHeader('database-mock')
+
+ expect(screen.getByText('15')).toBeInTheDocument()
+ expect(screen.getByText('container')).toBeInTheDocument()
+ expect(screen.getByText('private')).toBeInTheDocument()
+
+ await userEvent.click(screen.getByRole('button', { name: /connection uri/i }))
+
+ expect(mockGetDatabaseConnectionUri).toHaveBeenCalled()
+ expect(mockCopyToClipboard).toHaveBeenCalledWith('postgres://copied-uri')
+ expect(toast).toHaveBeenCalledWith(ToastEnum.SUCCESS, 'Credentials copied to clipboard')
+ })
+
+ it('does not show auto deploy badge for non auto-deploy job', () => {
+ renderServiceHeader('job-mock')
+
+ expect(screen.getByRole('heading', { name: 'test_lifecycle' })).toBeInTheDocument()
+ expect(screen.queryByText('auto-deploy-badge')).not.toBeInTheDocument()
+ })
+})
diff --git a/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx
new file mode 100644
index 00000000000..8fbf5ca164a
--- /dev/null
+++ b/libs/domains/services/feature/src/lib/service-overview/service-header/service-header.tsx
@@ -0,0 +1,286 @@
+import { useParams } from '@tanstack/react-router'
+import { type ApplicationGitRepository, type Credentials, type Environment } from 'qovery-typescript-axios'
+import { P, match } from 'ts-pattern'
+import { type AnyService } from '@qovery/domains/services/data-access'
+import {
+ IconEnum,
+ ServiceTypeEnum,
+ isHelmGitSource,
+ isHelmRepositorySource,
+ isJobContainerSource,
+ isJobGitSource,
+} from '@qovery/shared/enums'
+import { Badge, Button, ExternalLink, Heading, Icon, ToastEnum, Truncate, toast } from '@qovery/shared/ui'
+import { buildGitProviderUrl } from '@qovery/shared/util-git'
+import { useCopyToClipboard } from '@qovery/shared/util-hooks'
+import { containerRegistryKindToIcon, upperCaseFirstLetter } from '@qovery/shared/util-js'
+import AutoDeployBadge from '../../auto-deploy-badge/auto-deploy-badge'
+import { useMasterCredentials } from '../../hooks/use-master-credentials/use-master-credentials'
+import { getDatabaseConnectionUri } from '../../service-access-modal/service-access-modal'
+import { ServiceActionToolbar } from '../../service-action-toolbar/service-action-toolbar'
+import { ServiceAvatar } from '../../service-avatar/service-avatar'
+import { ServiceLinksPopover } from '../../service-links-popover/service-links-popover'
+import { ServiceStateChip } from '../../service-state-chip/service-state-chip'
+
+export function GitRepository({ gitRepository }: { gitRepository: ApplicationGitRepository }) {
+ return (
+ <>
+ {gitRepository.provider && (
+
+
+ {match(gitRepository.provider)
+ .with('GITHUB', () => 'GitHub')
+ .with('GITLAB', () => 'GitLab')
+ .with('BITBUCKET', () => 'Bitbucket')
+ .otherwise(() => upperCaseFirstLetter(gitRepository.provider))}
+
+ )}
+ {gitRepository.url && gitRepository.name && (
+
+
+
+ )}
+ {gitRepository.branch && gitRepository.url && (
+
+
+
+ )}
+ >
+ )
+}
+
+export interface ServiceHeaderProps {
+ environment: Environment
+ serviceId: string
+ service: AnyService
+}
+
+function ServiceHeaderContent({ environment, serviceId, service }: ServiceHeaderProps) {
+ const { organizationId = '', projectId = '' } = useParams({ strict: false })
+ const { data: masterCredentials } = useMasterCredentials({ serviceId, serviceType: service?.serviceType })
+
+ const [, copyToClipboard] = useCopyToClipboard()
+
+ const containerImage = match(service)
+ .with({ serviceType: ServiceTypeEnum.JOB, source: P.when(isJobContainerSource) }, ({ source }) => source.image)
+ .with({ serviceType: ServiceTypeEnum.CONTAINER }, ({ image_name, tag, registry }) => ({
+ image_name,
+ tag,
+ registry,
+ }))
+ .otherwise(() => undefined)
+
+ const helmRepository = match(service)
+ .with({ serviceType: 'HELM', source: P.when(isHelmRepositorySource) }, ({ source }) => source.repository)
+ .otherwise(() => undefined)
+
+ const databaseSource = match(service)
+ .with({ serviceType: ServiceTypeEnum.DATABASE }, ({ accessibility, mode, type, version }) => ({
+ accessibility,
+ mode,
+ type,
+ version,
+ masterCredentials,
+ }))
+ .otherwise(() => undefined)
+
+ const handleCopyCredentials = (credentials: Credentials) => {
+ if (!databaseSource) {
+ return
+ }
+ const connectionURI = getDatabaseConnectionUri(databaseSource, credentials)
+ copyToClipboard(connectionURI)
+ toast(ToastEnum.SUCCESS, 'Credentials copied to clipboard')
+ }
+
+ return (
+
+
+
+
+
+
{service.name}
+
+
+
+
+ {environment.cluster_name}
+
+
+
+
+ {service.description &&
{service.description}
}
+
+ {match(service)
+ .with(
+ { serviceType: 'APPLICATION' },
+ {
+ serviceType: 'JOB',
+ source: P.when(isJobGitSource),
+ },
+ {
+ serviceType: 'TERRAFORM',
+ },
+ {
+ serviceType: 'HELM',
+ source: P.when(isHelmGitSource),
+ },
+ (service) => {
+ const gitRepository = match(service)
+ .with({ serviceType: 'APPLICATION' }, ({ git_repository }) => git_repository)
+ .with({ serviceType: 'JOB' }, ({ source }) => source.docker?.git_repository)
+ .with({ serviceType: 'HELM' }, ({ source }) => source.git?.git_repository)
+ .with(
+ { serviceType: 'TERRAFORM' },
+ ({ terraform_files_source }) => terraform_files_source?.git?.git_repository
+ )
+ .exhaustive()
+
+ if (!gitRepository) {
+ return null
+ }
+
+ return
+ }
+ )
+ .otherwise(() => undefined)}
+ {containerImage && (
+ <>
+ {containerImage.registry && (
+
+
+
+
+
+
+ )}
+
+ {containerImage.image_name}
+
+
+
+
+ >
+ )}
+ {helmRepository && (
+ <>
+ {helmRepository.repository && (
+
+
+
+
+ )}
+
+ {helmRepository.chart_name}
+
+
+ {helmRepository.chart_version}
+
+ >
+ )}
+ {databaseSource && (
+ <>
+
+
+ {databaseSource.version}
+
+
{databaseSource.mode.toLowerCase()}
+
{databaseSource.accessibility?.toLowerCase()}
+ {databaseSource.masterCredentials && (
+
+ )}
+ >
+ )}
+ {'auto_deploy' in service &&
+ service.auto_deploy &&
+ match(service)
+ .with({ serviceType: 'APPLICATION' }, { serviceType: 'TERRAFORM' }, () => (
+
+ ))
+ .with({ serviceType: 'JOB' }, (job) =>
+ isJobGitSource(job.source) ?
: null
+ )
+ .with({ serviceType: 'HELM' }, (helm) =>
+ isHelmGitSource(helm.source) ?
: null
+ )
+ .otherwise(() => null)}
+
+
+
+
+
+
+
+ )
+}
+
+export function ServiceHeader(props: ServiceHeaderProps) {
+ return
+}
+
+export default ServiceHeader
diff --git a/libs/domains/services/feature/src/lib/service-overview/service-instance/service-instance.spec.tsx b/libs/domains/services/feature/src/lib/service-overview/service-instance/service-instance.spec.tsx
new file mode 100644
index 00000000000..8eb63e13b83
--- /dev/null
+++ b/libs/domains/services/feature/src/lib/service-overview/service-instance/service-instance.spec.tsx
@@ -0,0 +1,120 @@
+import { renderWithProviders, screen } from '@qovery/shared/util-tests'
+import { ServiceInstance } from './service-instance'
+
+jest.mock('../instance-metrics/instance-metrics', () => ({
+ InstanceMetrics: ({ children }: { children?: React.ReactNode }) => (
+
+ instance-metrics
+ {children}
+
+ ),
+}))
+
+jest.mock('../service-header/service-header', () => ({
+ GitRepository: () =>
mock-git-repository,
+}))
+
+describe('ServiceInstance', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('renders fixed scaling and resource limits for application service', () => {
+ const service = {
+ id: 'service-app-1',
+ serviceType: 'APPLICATION',
+ environment: { id: 'env-1' },
+ cpu: 500,
+ memory: 1024,
+ min_running_instances: 2,
+ max_running_instances: 2,
+ gpu: 0,
+ }
+
+ renderWithProviders(
)
+
+ expect(screen.getByText('Scaling method:')).toBeInTheDocument()
+ expect(screen.getByText('Fixed')).toBeInTheDocument()
+ expect(screen.getByText('Instances min/max:')).toBeInTheDocument()
+ expect(screen.getByText('2/2')).toBeInTheDocument()
+ expect(screen.getByText('vCPU limit:')).toBeInTheDocument()
+ expect(screen.getByText('Memory limit:')).toBeInTheDocument()
+ expect(screen.queryByText('GPU limit:')).not.toBeInTheDocument()
+ })
+
+ it('renders HPA scaling and gpu limit when min/max instances differ', () => {
+ const service = {
+ id: 'service-app-2',
+ serviceType: 'APPLICATION',
+ environment: { id: 'env-1' },
+ cpu: 250,
+ memory: 512,
+ min_running_instances: 1,
+ max_running_instances: 3,
+ gpu: 1,
+ }
+
+ renderWithProviders(
)
+
+ expect(screen.getByText('HPA')).toBeInTheDocument()
+ expect(screen.getByText('GPU limit:')).toBeInTheDocument()
+ })
+
+ it('renders cron scheduling details and cron help callout for cron jobs', () => {
+ const service = {
+ id: 'service-job-1',
+ serviceType: 'JOB',
+ job_type: 'CRON',
+ environment: { id: 'env-1' },
+ cpu: 100,
+ memory: 256,
+ max_duration_seconds: 600,
+ max_nb_restart: 2,
+ port: 8080,
+ schedule: {
+ cronjob: {
+ timezone: 'UTC',
+ scheduled_at: '0 0 * * *',
+ },
+ },
+ }
+
+ renderWithProviders(
)
+
+ expect(screen.getByText('Scheduling (UTC):')).toBeInTheDocument()
+ expect(screen.getByText('Restart (max):')).toBeInTheDocument()
+ expect(screen.getByText('2')).toBeInTheDocument()
+ expect(screen.getByText('Duration (max):')).toBeInTheDocument()
+ expect(screen.getByText('600 s')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: /see documentation/i })).toBeInTheDocument()
+ })
+
+ it('renders helm values override from git repository with arguments', () => {
+ const service = {
+ id: 'service-helm-1',
+ serviceType: 'HELM',
+ environment: { id: 'env-1' },
+ values_override: {
+ file: {
+ git: {
+ git_repository: {
+ url: 'https://github.com/Qovery/console',
+ name: 'Qovery/console',
+ },
+ },
+ },
+ set: ['key=value'],
+ set_json: [],
+ set_string: [],
+ },
+ }
+
+ renderWithProviders(
)
+
+ expect(screen.getByText('Type:')).toBeInTheDocument()
+ expect(screen.getByText('Git repository')).toBeInTheDocument()
+ expect(screen.getByText('mock-git-repository')).toBeInTheDocument()
+ expect(screen.getByText('Override with arguments:')).toBeInTheDocument()
+ expect(screen.getByText('Yes')).toBeInTheDocument()
+ })
+})
diff --git a/libs/domains/services/feature/src/lib/service-overview/service-instance/service-instance.tsx b/libs/domains/services/feature/src/lib/service-overview/service-instance/service-instance.tsx
new file mode 100644
index 00000000000..d10f742dcaf
--- /dev/null
+++ b/libs/domains/services/feature/src/lib/service-overview/service-instance/service-instance.tsx
@@ -0,0 +1,159 @@
+import { ServiceTypeEnum } from 'qovery-typescript-axios'
+import { type ReactNode, useMemo } from 'react'
+import { match } from 'ts-pattern'
+import { type AnyService } from '@qovery/domains/services/data-access'
+import { ExternalLink, Icon } from '@qovery/shared/ui'
+import { formatCronExpression, formatMetric } from '@qovery/shared/util-js'
+import { InstanceMetrics } from '../instance-metrics/instance-metrics'
+import { GitRepository } from '../service-header/service-header'
+
+function LabelValue({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+ {label}: {children}
+
+ )
+}
+
+export function ServiceInstance({ service }: { service: AnyService }) {
+ const isCronJob = useMemo(() => service?.serviceType === 'JOB' && service.job_type === 'CRON', [service])
+
+ const resources = match(service)
+ .with({ serviceType: ServiceTypeEnum.CONTAINER }, { serviceType: ServiceTypeEnum.APPLICATION }, (s) => {
+ const { cpu, memory, min_running_instances, max_running_instances, gpu } = s
+
+ // Determine autoscaling mode using the same logic as other parts of the app
+ let autoscalingMode = 'Fixed'
+ if (s.autoscaling?.mode === 'KEDA') {
+ autoscalingMode = 'KEDA'
+ } else if (min_running_instances !== max_running_instances) {
+ autoscalingMode = 'HPA'
+ }
+
+ return (
+ <>
+
{autoscalingMode}
+
+ {min_running_instances}/{max_running_instances}
+
+
{cpu && formatMetric({ current: cpu, unit: 'mCPU' })}
+
{memory && formatMetric({ current: memory, unit: 'MiB' })}
+ {!gpu || gpu === 0 ? null : (
+
{gpu && formatMetric({ current: gpu, unit: 'GPU' })}
+ )}
+ >
+ )
+ })
+ .with({ serviceType: ServiceTypeEnum.DATABASE }, ({ cpu, memory, storage, instance_type, mode }) => (
+ <>
+ {mode !== 'MANAGED' && (
+ <>
+
{cpu && formatMetric({ current: cpu, unit: 'mCPU' })}
+
{memory && formatMetric({ current: memory, unit: 'MiB' })}
+ >
+ )}
+
{storage && formatMetric({ current: storage, unit: 'GiB' })}
+ {mode !== 'CONTAINER' &&
{instance_type}}
+ >
+ ))
+ .with({ serviceType: ServiceTypeEnum.JOB }, (job) => {
+ const { cpu, memory, max_duration_seconds, max_nb_restart, port, gpu } = job
+ return (
+ <>
+ {match(job)
+ .with({ job_type: 'LIFECYCLE' }, ({ schedule }) => (
+
+ {[schedule.on_start && 'Deploy', schedule.on_stop && 'Stop', schedule.on_delete && 'Delete']
+ .filter(Boolean)
+ .join(' - ') || undefined}
+
+ ))
+ .with({ job_type: 'CRON' }, ({ schedule }) => (
+
+ {formatCronExpression(schedule.cronjob?.scheduled_at)}
+
+ ))
+ .exhaustive()}
+
{max_nb_restart}
+
+ {max_duration_seconds ? `${max_duration_seconds} s` : undefined}
+
+
{cpu && formatMetric({ current: cpu, unit: 'mCPU' })}
+
{memory && formatMetric({ current: memory, unit: 'MiB' })}
+ {!gpu || gpu === 0 ? null : (
+
{gpu && formatMetric({ current: gpu, unit: 'GPU' })}
+ )}
+ {port &&
{port}}
+ >
+ )
+ })
+ .otherwise(() => null)
+
+ const valuesOverride = match(service)
+ .with({ serviceType: 'HELM' }, (service) => {
+ const {
+ values_override: { file, set, set_json, set_string },
+ } = service
+ const overrideWithArguments = (
+
+ {set?.length || set_json?.length || set_string?.length ? 'Yes' : 'No'}
+
+ )
+
+ if (file?.git?.git_repository) {
+ return (
+ <>
+
+ Git repository
+
+
+ {overrideWithArguments}
+ >
+ )
+ } else if (file?.raw) {
+ return (
+ <>
+
Raw YAML
+ {overrideWithArguments}
+ >
+ )
+ } else {
+ return overrideWithArguments
+ }
+ })
+ .otherwise(() => null)
+
+ return (
+
+
+
+
+ {resources && resources}
+ {valuesOverride && valuesOverride}
+
+
+
+
+
+ {isCronJob && (
+
+
+
+ The number of past Completed or Failed job execution retained in the history and their TTL can be
+ customized in the advanced settings.
+
+
+ See documentation
+
+
+ )}
+
+
+
+ )
+}
+
+export default ServiceInstance
diff --git a/libs/domains/services/feature/src/lib/service-overview/service-last-deployment/service-last-deployment.spec.tsx b/libs/domains/services/feature/src/lib/service-overview/service-last-deployment/service-last-deployment.spec.tsx
new file mode 100644
index 00000000000..f66b515a0d3
--- /dev/null
+++ b/libs/domains/services/feature/src/lib/service-overview/service-last-deployment/service-last-deployment.spec.tsx
@@ -0,0 +1,113 @@
+import { renderWithProviders, screen } from '@qovery/shared/util-tests'
+import { ServiceLastDeployment } from './service-last-deployment'
+
+const mockUseDeploymentHistory = jest.fn()
+
+jest.mock('@tanstack/react-router', () => ({
+ ...jest.requireActual('@tanstack/react-router'),
+ useParams: () => ({ organizationId: 'org-1', projectId: 'proj-1' }),
+}))
+
+jest.mock('../../hooks/use-deployment-history/use-deployment-history', () => ({
+ useDeploymentHistory: (params: unknown) => mockUseDeploymentHistory(params),
+}))
+
+jest.mock('../../last-commit/last-commit', () => ({
+ LastCommit: () =>
mock-last-commit,
+}))
+
+jest.mock('../../last-commit-author/last-commit-author', () => ({
+ LastCommitAuthor: () =>
mock-last-commit-author,
+}))
+
+jest.mock('@qovery/shared/util-dates', () => ({
+ ...jest.requireActual('@qovery/shared/util-dates'),
+ dateUTCString: () => 'mocked-date',
+ timeAgo: () => 'mocked-time-ago',
+}))
+
+const baseDeployment = {
+ identifier: {
+ execution_id: 'exec-123',
+ service_id: 'service-123',
+ service_type: 'APPLICATION',
+ name: 'my-app',
+ },
+ auditing_data: {
+ created_at: '2025-01-23T08:55:20.092474Z',
+ updated_at: '2025-01-23T08:55:42.898794Z',
+ origin: 'CONSOLE',
+ triggered_by: 'John Doe',
+ },
+ status: 'SUCCESS',
+ status_details: {
+ action: 'DEPLOY',
+ status: 'SUCCESS',
+ sub_action: 'NONE',
+ },
+ details: {
+ image_name: 'my-image',
+ tag: 'v1.2.3',
+ },
+ icon_uri: 'app://qovery-console/application',
+ total_duration: 'PT16.503S',
+}
+
+describe('ServiceLastDeployment', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('renders an empty state when no deployment exists', () => {
+ mockUseDeploymentHistory.mockReturnValue({
+ data: [],
+ isFetched: true,
+ })
+
+ renderWithProviders(
)
+
+ expect(screen.getByText('Application has never been deployed')).toBeInTheDocument()
+ expect(screen.getByText('Deploy the application first')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /deploy now/i })).toBeInTheDocument()
+ })
+
+ it('renders the image tag version pill when deployment details contains an image tag', () => {
+ mockUseDeploymentHistory.mockReturnValue({
+ data: [baseDeployment],
+ isFetched: true,
+ })
+
+ renderWithProviders(
)
+
+ expect(screen.getByRole('button', { name: 'v1.2.3' })).toBeInTheDocument()
+ })
+
+ it('renders commit block when service has a git repository', () => {
+ mockUseDeploymentHistory.mockReturnValue({
+ data: [baseDeployment],
+ isFetched: true,
+ })
+
+ const service = {
+ id: 'service-123',
+ name: 'my-app',
+ serviceType: 'APPLICATION',
+ environment: { id: 'env-1' },
+ git_repository: {
+ provider: 'GITHUB',
+ owner: 'qovery',
+ url: 'https://github.com/Qovery/console',
+ name: 'Qovery/console',
+ branch: 'main',
+ root_path: '/',
+ },
+ }
+
+ renderWithProviders(
+
+ )
+
+ expect(screen.getByText('mock-last-commit')).toBeInTheDocument()
+ expect(screen.getByText('mock-last-commit-author')).toBeInTheDocument()
+ })
+})
diff --git a/libs/domains/services/feature/src/lib/service-overview/service-last-deployment/service-last-deployment.tsx b/libs/domains/services/feature/src/lib/service-overview/service-last-deployment/service-last-deployment.tsx
new file mode 100644
index 00000000000..3af897cce2c
--- /dev/null
+++ b/libs/domains/services/feature/src/lib/service-overview/service-last-deployment/service-last-deployment.tsx
@@ -0,0 +1,173 @@
+import { useParams } from '@tanstack/react-router'
+import { type ApplicationGitRepository } from 'qovery-typescript-axios'
+import { Suspense } from 'react'
+import { P, match } from 'ts-pattern'
+import { type AnyService } from '@qovery/domains/services/data-access'
+import { isHelmGitSource, isJobGitSource } from '@qovery/shared/enums'
+import {
+ Button,
+ CopyToClipboard,
+ DeploymentAction,
+ EmptyState,
+ Icon,
+ Skeleton,
+ StatusChip,
+ Tooltip,
+} from '@qovery/shared/ui'
+import { dateUTCString, timeAgo } from '@qovery/shared/util-dates'
+import { useDeploymentHistory } from '../../hooks/use-deployment-history/use-deployment-history'
+import { LastCommitAuthor, type LastCommitAuthorProps } from '../../last-commit-author/last-commit-author'
+import { LastCommit, type LastCommitProps } from '../../last-commit/last-commit'
+import { isDeploymentHistory } from '../../service-deployment-list/service-deployment-list'
+
+const DotSeparator = () => (
+
+)
+
+function getGitRepository(service: AnyService): ApplicationGitRepository | undefined {
+ return match(service)
+ .with({ serviceType: 'APPLICATION' }, ({ git_repository }) => git_repository)
+ .with({ serviceType: 'JOB', source: P.when(isJobGitSource) }, ({ source }) => source.docker?.git_repository)
+ .with({ serviceType: 'HELM', source: P.when(isHelmGitSource) }, ({ source }) => source.git?.git_repository)
+ .with({ serviceType: 'TERRAFORM' }, ({ terraform_files_source }) => terraform_files_source?.git?.git_repository)
+ .otherwise(() => undefined)
+}
+
+export interface ServiceLastDeploymentProps {
+ serviceId: string
+ serviceType: Parameters
[0]['serviceType']
+ service?: AnyService
+}
+export function ServiceLastDeploymentSkeleton() {
+ return (
+
+
+
+
+
+ )
+}
+
+function ServiceLastDeploymentContent({ serviceId, serviceType, service }: ServiceLastDeploymentProps) {
+ const { organizationId = '', projectId = '' } = useParams({ strict: false })
+ const { data: deploymentHistory = [] } = useDeploymentHistory({
+ serviceId,
+ serviceType,
+ suspense: true,
+ })
+
+ const lastDeployment = deploymentHistory[0]
+ const gitRepository = service ? getGitRepository(service) : undefined
+ const showGitCommit =
+ Boolean(gitRepository) &&
+ Boolean(service?.id && service?.name && service?.serviceType && 'environment' in service && service.environment)
+
+ if (!lastDeployment) {
+ return (
+
+
+
+ )
+ }
+
+ const versionPill = isDeploymentHistory(lastDeployment)
+ ? match(lastDeployment.details)
+ .with({ repository: P.select({ chart_name: P.string, chart_version: P.string }) }, ({ chart_version }) => (
+
+
+
+ ))
+ .with({ image_name: P.string, tag: P.string }, ({ tag }) => (
+
+
+
+
+
+ ))
+ .otherwise(() => null)
+ : null
+
+ const gitBlock =
+ showGitCommit && gitRepository && service ? (
+
+
+
+
+ {'created_at' in (lastDeployment.auditing_data ?? {}) && lastDeployment.auditing_data.created_at && (
+ <>
+
+
+ Lasted{' '}
+
+ {timeAgo(new Date(lastDeployment.auditing_data.created_at))}
+
+
+ >
+ )}
+
+
+
+
+ ) : null
+
+ return (
+
+
+
+
+
+
+ {showGitCommit ? (
+ gitBlock
+ ) : versionPill ? (
+ <>
+
+ {versionPill}
+ >
+ ) : null}
+
+
+ )
+}
+
+export function ServiceLastDeployment(props: ServiceLastDeploymentProps) {
+ return (
+ }>
+
+
+ )
+}
diff --git a/libs/domains/services/feature/src/lib/service-overview/service-overview-skeleton.tsx b/libs/domains/services/feature/src/lib/service-overview/service-overview-skeleton.tsx
new file mode 100644
index 00000000000..505e81af50b
--- /dev/null
+++ b/libs/domains/services/feature/src/lib/service-overview/service-overview-skeleton.tsx
@@ -0,0 +1,58 @@
+import { type Environment } from 'qovery-typescript-axios'
+import { type ReactNode } from 'react'
+import { Heading, Section, Skeleton } from '@qovery/shared/ui'
+import { InstanceMetricsSkeleton } from './instance-metrics/instance-metrics-skeleton'
+import { ServiceLastDeploymentSkeleton } from './service-last-deployment/service-last-deployment'
+
+export interface ServiceOverviewSkeletonProps {
+ environment?: Environment
+ hasNoMetrics?: boolean
+ observabilityCallout?: ReactNode
+}
+
+export function ServiceOverviewSkeleton({ hasNoMetrics = false, observabilityCallout }: ServiceOverviewSkeletonProps) {
+ return (
+
+
+
+
+ {hasNoMetrics && observabilityCallout}
+
+
+ Last deployment
+
+
+
+
+
+
+
+
+ )
+}
+
+export default ServiceOverviewSkeleton
diff --git a/libs/domains/services/feature/src/lib/service-overview/service-overview.spec.tsx b/libs/domains/services/feature/src/lib/service-overview/service-overview.spec.tsx
new file mode 100644
index 00000000000..a049c9599fe
--- /dev/null
+++ b/libs/domains/services/feature/src/lib/service-overview/service-overview.spec.tsx
@@ -0,0 +1,170 @@
+import { DatabaseModeEnum } from 'qovery-typescript-axios'
+import { type ReactNode } from 'react'
+import { renderWithProviders, screen } from '@qovery/shared/util-tests'
+import { ServiceOverview } from './service-overview'
+
+const mockUseService = jest.fn()
+const mockUseRunningStatus = jest.fn()
+const mockUseFeatureFlagVariantKey = jest.fn()
+
+jest.mock('@tanstack/react-router', () => ({
+ ...jest.requireActual('@tanstack/react-router'),
+ useParams: () => ({ environmentId: 'env-1', serviceId: 'service-1' }),
+}))
+
+jest.mock('posthog-js/react', () => ({
+ useFeatureFlagVariantKey: () => mockUseFeatureFlagVariantKey(),
+}))
+
+jest.mock('@qovery/shared/ui', () => ({
+ ...jest.requireActual('@qovery/shared/ui'),
+ Heading: ({ children }: { children?: ReactNode }) => {children}
,
+ Section: ({ children }: { children?: ReactNode }) => ,
+ Link: ({ children }: { children?: ReactNode }) => {children},
+ Icon: () => icon,
+ TabsPrimitives: {
+ Tabs: {
+ Root: ({ children }: { children?: ReactNode }) => {children}
,
+ List: ({ children }: { children?: ReactNode }) => {children}
,
+ Trigger: ({ children }: { children?: ReactNode }) => ,
+ Content: ({ children }: { children?: ReactNode }) => {children}
,
+ },
+ },
+}))
+
+jest.mock('../hooks/use-service/use-service', () => ({
+ useService: () => mockUseService(),
+}))
+
+jest.mock('../hooks/use-running-status/use-running-status', () => ({
+ useRunningStatus: () => mockUseRunningStatus(),
+}))
+
+jest.mock('./service-header/service-header', () => ({
+ ServiceHeader: () => service-header
,
+}))
+
+jest.mock('./instance-metrics/instance-metrics', () => ({
+ InstanceMetrics: () => instance-metrics
,
+}))
+
+jest.mock('./service-instance/service-instance', () => ({
+ ServiceInstance: () => service-instance
,
+}))
+
+jest.mock('./service-last-deployment/service-last-deployment', () => ({
+ ServiceLastDeployment: () => service-last-deployment
,
+}))
+
+jest.mock('../keda/scaled-object-status/scaled-object-status', () => ({
+ ScaledObjectStatus: () => scaled-object-status
,
+}))
+
+jest.mock('../need-redeploy-flag/need-redeploy-flag', () => ({
+ NeedRedeployFlag: () => need-redeploy-flag
,
+}))
+
+jest.mock('@qovery/domains/variables/feature', () => ({
+ OutputVariables: () => output-variables
,
+}))
+
+describe('ServiceOverview', () => {
+ const environment = {
+ id: 'env-1',
+ organization: { id: 'org-1' },
+ project: { id: 'project-1' },
+ } as never
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockUseFeatureFlagVariantKey.mockReturnValue(false)
+ mockUseRunningStatus.mockReturnValue({ data: null })
+ })
+
+ it('renders nothing when service is missing', () => {
+ mockUseService.mockReturnValue({ data: undefined })
+
+ renderWithProviders()
+
+ expect(screen.queryByText('service-header')).not.toBeInTheDocument()
+ })
+
+ it('shows observability callout only when hasNoMetrics is true', () => {
+ mockUseService.mockReturnValue({
+ data: { id: 'service-1', serviceType: 'APPLICATION', autoscaling: { mode: 'FIXED' } },
+ })
+
+ const { rerender } = renderWithProviders(
+ observability-callout }
+ />
+ )
+
+ expect(screen.queryByText('observability-callout')).not.toBeInTheDocument()
+
+ rerender(
+