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 @@ -88,6 +88,7 @@ function ClusterDeploymentLogsPage({
triggeredBy={deployment?.auditing_data.triggered_by}
actionStatus={deployment?.action_status}
totalDuration={deployment?.total_duration}
operation={deployment?.operation}
/>
</div>
{deployment?.action_status === 'ONGOING' && deployment?.reason === 'MAINTENANCE' && (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ClusterDeploymentHistory } from 'qovery-typescript-axios'
import { type ClusterDeploymentHistory, ClusterDeploymentHistoryOperationEnum } from 'qovery-typescript-axios'
import { type ReactNode } from 'react'
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import { ClusterDeploymentList } from './cluster-deployment-list'
Expand All @@ -19,6 +19,7 @@ const defaultDeploymentHistory: ClusterDeploymentHistory[] = [
status: 'DEPLOYED',
action_status: 'SUCCESS',
trigger_action: 'DEPLOY',
operation: ClusterDeploymentHistoryOperationEnum.CLUSTER,
is_dry_run: false,
reason: 'MAINTENANCE',
total_duration: 'PT16.503S',
Expand Down Expand Up @@ -67,6 +68,44 @@ describe('ClusterDeploymentList', () => {
expect(screen.getByText('Maintenance')).toBeInTheDocument()
})

it('should render the Operator action with its maintenance badge when operation is OPERATOR_UPDATE', () => {
mockDeploymentHistory = [
{
...defaultDeploymentHistory[0],
operation: ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE,
},
]

renderWithProviders(<ClusterDeploymentList organizationId="org-123" clusterId="cluster-123" />)

const action = screen.getByText('Operator').parentElement
expect(screen.queryByText('Deploy')).not.toBeInTheDocument()
expect(screen.getByText('Maintenance')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Operator update/ }).tagName).toBe('TR')
expect(action?.querySelector('.fa-rotate-right')).toBeInTheDocument()
expect(action?.querySelector('[tabindex]')).toBeNull()
expect(action?.closest('[tabindex]')?.tagName).toBe('TR')
})

it('should render the Deploy action when operation is CLUSTER', () => {
renderWithProviders(<ClusterDeploymentList organizationId="org-123" clusterId="cluster-123" />)

expect(screen.getByText('Deploy')).toBeInTheDocument()
expect(screen.queryByText('Operator')).not.toBeInTheDocument()
expect(screen.getByText('Maintenance')).toBeInTheDocument()
})

it('should render the Deploy action when operation is missing from the payload', () => {
const legacyDeployment: Partial<ClusterDeploymentHistory> = { ...defaultDeploymentHistory[0] }
delete legacyDeployment.operation
mockDeploymentHistory = [legacyDeployment as ClusterDeploymentHistory]

renderWithProviders(<ClusterDeploymentList organizationId="org-123" clusterId="cluster-123" />)

expect(screen.getByText('Deploy')).toBeInTheDocument()
expect(screen.queryByText('Operator')).not.toBeInTheDocument()
})

it('should render a dry run badge when the deployment is a dry run', () => {
mockDeploymentHistory = [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,8 @@ export function ClusterDeploymentList({ organizationId, clusterId }: ClusterDepl

return (
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<DeploymentAction status={data.trigger_action} />
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<DeploymentAction status={data.trigger_action} operation={data.operation} />
{data.reason === 'MAINTENANCE' && (
<Badge color="neutral" variant="surface" className="font-medium">
Maintenance
Expand All @@ -166,7 +166,7 @@ export function ClusterDeploymentList({ organizationId, clusterId }: ClusterDepl
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-2">
{data.action_status === 'ERROR' && (
<DevopsCopilotTroubleshootTrigger
source="cluster-deployment-list"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { TooltipProvider } from '@radix-ui/react-tooltip'
import posthog from 'posthog-js'
import {
type ClusterDeploymentHistory,
ClusterDeploymentHistoryOperationEnum,
ClusterStateEnum,
type ClusterStatus,
DeploymentHistoryTriggerAction,
Expand Down Expand Up @@ -46,28 +48,35 @@ jest.mock('@qovery/shared/ui', () => ({

let mockClusterDeploymentHistoryProps: UseClusterDeploymentHistoryProps | undefined

const defaultDeploymentHistory: ClusterDeploymentHistory[] = [
{
identifier: {
deployment_id: 'deployment-1',
execution_id: 'execution-1',
cluster_id: 'cluster-1',
},
auditing_data: {
created_at: '2026-08-10T12:30:00Z',
updated_at: '2026-08-10T12:31:00Z',
},
status: 'DEPLOYED',
action_status: 'SUCCESS',
trigger_action: 'DEPLOY',
operation: ClusterDeploymentHistoryOperationEnum.CLUSTER,
is_dry_run: false,
reason: 'UNSPECIFIED',
total_duration: 'PT1M',
},
]

let mockDeploymentHistory = defaultDeploymentHistory

jest.mock('../hooks/use-cluster-deployment-history/use-cluster-deployment-history', () => ({
useClusterDeploymentHistory: (props: UseClusterDeploymentHistoryProps) => {
mockClusterDeploymentHistoryProps = props

return {
data: [
{
identifier: {
deployment_id: 'deployment-1',
execution_id: 'execution-1',
cluster_id: 'cluster-1',
},
auditing_data: {
created_at: '2026-08-10T12:30:00Z',
},
status: 'DEPLOYED',
action_status: 'SUCCESS',
trigger_action: 'DEPLOY',
reason: 'UNSPECIFIED',
total_duration: 'PT1M',
},
],
data: mockDeploymentHistory,
isFetched: true,
}
},
Expand Down Expand Up @@ -101,6 +110,7 @@ describe('ClusterLastDeploymentSection', () => {
beforeEach(() => {
jest.clearAllMocks()
mockClusterDeploymentHistoryProps = undefined
mockDeploymentHistory = defaultDeploymentHistory
})

it('renders the latest cluster deployment row linked to its deployment logs', () => {
Expand Down Expand Up @@ -181,6 +191,92 @@ describe('ClusterLastDeploymentSection', () => {
expect(screen.getByText('Maintenance')).toBeInTheDocument()
})

it('renders the Operator action with the reason when the last deployment is an Operator update', () => {
mockDeploymentHistory = [
{ ...defaultDeploymentHistory[0], operation: ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE },
]

renderWithProviders(
<ClusterLastDeploymentSection
organizationId="org-1"
clusterId="cluster-1"
clusterStatus={{ ...baseClusterStatus, reason: DeploymentInfraReason.MAINTENANCE }}
/>
)

expect(screen.getByText('Operator')).toBeInTheDocument()
expect(screen.queryByText('Deploy')).not.toBeInTheDocument()
expect(screen.getByText('Maintenance')).toBeInTheDocument()
expect(screen.getByText('Operator').closest('a')?.querySelector('[tabindex]')).toBeNull()
expect(screen.getByRole('link', { name: /Operator update/ })).toHaveAttribute(
'href',
'/organization/org-1/cluster/cluster-1/deployments/logs/deployment-1'
)
})

it('renders the Deploy action when the cluster status has no last execution id', () => {
mockDeploymentHistory = [
{ ...defaultDeploymentHistory[0], operation: ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE },
]

renderWithProviders(
<ClusterLastDeploymentSection
organizationId="org-1"
clusterId="cluster-1"
clusterStatus={{ ...baseClusterStatus, last_execution_id: undefined }}
/>
)

expect(screen.getByText('Deploy')).toBeInTheDocument()
expect(screen.queryByText('Operator')).not.toBeInTheDocument()
})

it('renders the Deploy action when the last deployment is a cluster deployment', () => {
renderWithProviders(
<ClusterLastDeploymentSection organizationId="org-1" clusterId="cluster-1" clusterStatus={baseClusterStatus} />
)

expect(screen.getByText('Deploy')).toBeInTheDocument()
expect(screen.queryByText('Operator')).not.toBeInTheDocument()
})

it('renders the Deploy action when operation is missing from the payload', () => {
const legacyDeployment: Partial<ClusterDeploymentHistory> = { ...defaultDeploymentHistory[0] }
delete legacyDeployment.operation
mockDeploymentHistory = [legacyDeployment as ClusterDeploymentHistory]

renderWithProviders(
<ClusterLastDeploymentSection organizationId="org-1" clusterId="cluster-1" clusterStatus={baseClusterStatus} />
)

expect(screen.getByText('Deploy')).toBeInTheDocument()
expect(screen.queryByText('Operator')).not.toBeInTheDocument()
})

it('ignores a stale Operator update when the cluster status points to a newer execution', () => {
mockDeploymentHistory = [
{ ...defaultDeploymentHistory[0], operation: ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE },
]

renderWithProviders(
<ClusterLastDeploymentSection
organizationId="org-1"
clusterId="cluster-1"
clusterStatus={
{
...baseClusterStatus,
status: ClusterStateEnum.STOPPED,
last_execution_id: 'execution-2',
trigger_action: DeploymentHistoryTriggerAction.STOP,
} as ClusterStatus & { trigger_action: DeploymentHistoryTriggerAction }
}
/>
)

expect(screen.getByText('Stop')).toBeInTheDocument()
expect(screen.queryByText('Operator')).not.toBeInTheDocument()
})

it('renders an empty state when the cluster has no deployment information', () => {
renderWithProviders(
<ClusterLastDeploymentSection
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ export function ClusterLastDeploymentSection({
const lastDeployment = deploymentHistory[0]
const hasLastDeployment = Boolean(clusterStatus?.last_deployment_date || clusterStatus?.last_execution_id)
const deploymentReasonLabel = getDeploymentReasonLabel(clusterStatus?.reason)
const lastDeploymentOperation =
lastDeployment?.identifier.execution_id &&
lastDeployment.identifier.execution_id === clusterStatus?.last_execution_id
? lastDeployment.operation
: undefined

const handleLaunchDiagnostic = () => {
if (!clusterStatus) return
Expand Down Expand Up @@ -170,7 +175,10 @@ export function ClusterLastDeploymentSection({
>
<div className="flex flex-wrap items-center gap-2.5 text-sm text-neutral">
<span className="font-medium">
<DeploymentAction status={getClusterDeploymentAction(clusterStatus)} />
<DeploymentAction
status={getClusterDeploymentAction(clusterStatus)}
operation={lastDeploymentOperation}
/>
</span>
<StatusChip status={clusterStatus.status} />
{deploymentReasonLabel && (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import download from 'downloadjs'
import { ClusterStateEnum } from 'qovery-typescript-axios'
import { ClusterDeploymentHistoryOperationEnum, ClusterStateEnum } from 'qovery-typescript-axios'
import { type RefObject } from 'react'
import { clusterFactoryMock, clusterLogFactoryMock } from '@qovery/shared/factories'
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
Expand Down Expand Up @@ -45,6 +45,20 @@ describe('ClusterHeaderLogs', () => {
expect(screen.getByText('Romain Billard')).toBeInTheDocument()
})

it('should render an Operator update badge when operation is OPERATOR_UPDATE', () => {
renderWithProviders(
<ClusterHeaderLogs {...props} operation={ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE} />
)

expect(screen.getByText('Operator update')).toBeInTheDocument()
})

it('should not render an Operator update badge when operation is CLUSTER', () => {
renderWithProviders(<ClusterHeaderLogs {...props} operation={ClusterDeploymentHistoryOperationEnum.CLUSTER} />)

expect(screen.queryByText('Operator update')).not.toBeInTheDocument()
})

it('should trigger scroll up on click', async () => {
const { userEvent } = renderWithProviders(<ClusterHeaderLogs {...props} />)
const scrollUpButton = screen.getByTestId('scroll-up-button')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import download from 'downloadjs'
import posthog from 'posthog-js'
import {
type Cluster,
ClusterDeploymentHistoryOperationEnum,
type ClusterLogs,
type ClusterStatus,
type DeploymentHistoryActionStatus,
OrganizationEventOrigin,
} from 'qovery-typescript-axios'
import { type RefObject, useContext } from 'react'
import { DevopsCopilotContext } from '@qovery/shared/devops-copilot/feature'
import { Button, Icon, Tooltip, Truncate } from '@qovery/shared/ui'
import { Badge, Button, Icon, Tooltip, Truncate } from '@qovery/shared/ui'
import { dateDifference, dateFullFormat, dateUTCString, formatDuration } from '@qovery/shared/util-dates'
import { useIntervalTick } from '@qovery/shared/util-hooks'
import { EventOriginIcon } from '@qovery/shared/util-icons'
Expand All @@ -35,6 +36,7 @@ export interface ClusterHeaderLogsProps {
triggeredBy?: string | null
actionStatus?: DeploymentHistoryActionStatus
totalDuration?: string | null
operation?: ClusterDeploymentHistoryOperationEnum
}

export function ClusterHeaderLogs({
Expand All @@ -49,6 +51,7 @@ export function ClusterHeaderLogs({
triggeredBy,
actionStatus,
totalDuration,
operation,
}: ClusterHeaderLogsProps) {
const { setDevopsCopilotOpen, sendMessageRef } = useContext(DevopsCopilotContext)

Expand Down Expand Up @@ -90,6 +93,11 @@ export function ClusterHeaderLogs({
<Icon className="text-base" iconName="arrow-left" iconStyle="regular" />
</Button>
)}
{operation === ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE && (
<Badge color="neutral" variant="surface" className="font-medium">
Operator update
</Badge>
)}
{createdAt && (
<>
<span
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { getTriggerAction } from './deployment-action'
import { ClusterDeploymentHistoryOperationEnum } from 'qovery-typescript-axios'
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import { DeploymentAction, getTriggerAction } from './deployment-action'

describe('getTriggerAction', () => {
it('returns undefined without status details', () => {
Expand All @@ -18,3 +20,34 @@ describe('getTriggerAction', () => {
expect(getTriggerAction({})).toBe('UNKNOWN')
})
})

describe('DeploymentAction', () => {
it('renders the action label derived from the status', () => {
renderWithProviders(<DeploymentAction status="DEPLOY" />)

expect(screen.getByText('Deploy')).toBeInTheDocument()
})

it('renders the Operator action with a screen-reader description for an Operator update', () => {
const { container } = renderWithProviders(
<DeploymentAction status="DEPLOY" operation={ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE} />
)

expect(screen.getByText('Operator')).toHaveAttribute('aria-hidden', 'true')
expect(screen.getByText('Operator update')).toHaveClass('sr-only')
expect(screen.queryByText('Deploy')).not.toBeInTheDocument()
expect(container.querySelector('.fa-rotate-right')).toBeInTheDocument()
expect(container.querySelector('.fa-rocket')).not.toBeInTheDocument()
expect(container.querySelector('[tabindex]')).toBeNull()
})

it('renders the status action without a description for a cluster operation', () => {
const { container } = renderWithProviders(
<DeploymentAction status="DEPLOY" operation={ClusterDeploymentHistoryOperationEnum.CLUSTER} />
)

expect(screen.getByText('Deploy')).not.toHaveAttribute('aria-hidden')
expect(screen.queryByText('Operator update')).not.toBeInTheDocument()
expect(container.querySelector('.fa-rocket')).toBeInTheDocument()
})
})
Loading
Loading