diff --git a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/logs/$deploymentId.tsx b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/logs/$deploymentId.tsx index d64c36585a5..98d1d4e6880 100644 --- a/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/logs/$deploymentId.tsx +++ b/apps/console/src/routes/_authenticated/organization/$organizationId/cluster/$clusterId/deployments/logs/$deploymentId.tsx @@ -88,6 +88,7 @@ function ClusterDeploymentLogsPage({ triggeredBy={deployment?.auditing_data.triggered_by} actionStatus={deployment?.action_status} totalDuration={deployment?.total_duration} + operation={deployment?.operation} /> {deployment?.action_status === 'ONGOING' && deployment?.reason === 'MAINTENANCE' && ( diff --git a/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.spec.tsx index 50bf60934ef..b5ba9d8433c 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.spec.tsx @@ -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' @@ -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', @@ -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() + + 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() + + 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 = { ...defaultDeploymentHistory[0] } + delete legacyDeployment.operation + mockDeploymentHistory = [legacyDeployment as ClusterDeploymentHistory] + + renderWithProviders() + + 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 = [ { diff --git a/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.tsx b/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.tsx index 03aabb583da..32746321701 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-list.tsx @@ -153,8 +153,8 @@ export function ClusterDeploymentList({ organizationId, clusterId }: ClusterDepl return (
-
- +
+ {data.reason === 'MAINTENANCE' && ( Maintenance @@ -166,7 +166,7 @@ export function ClusterDeploymentList({ organizationId, clusterId }: ClusterDepl )}
-
+
{data.action_status === 'ERROR' && ( ({ 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, } }, @@ -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', () => { @@ -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( + + ) + + 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( + + ) + + 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( + + ) + + 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 = { ...defaultDeploymentHistory[0] } + delete legacyDeployment.operation + mockDeploymentHistory = [legacyDeployment as ClusterDeploymentHistory] + + renderWithProviders( + + ) + + 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( + + ) + + expect(screen.getByText('Stop')).toBeInTheDocument() + expect(screen.queryByText('Operator')).not.toBeInTheDocument() + }) + it('renders an empty state when the cluster has no deployment information', () => { renderWithProviders( { if (!clusterStatus) return @@ -170,7 +175,10 @@ export function ClusterLastDeploymentSection({ >
- + {deploymentReasonLabel && ( diff --git a/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.spec.tsx index 0487137548d..d7f94026945 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.spec.tsx @@ -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' @@ -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( + + ) + + expect(screen.getByText('Operator update')).toBeInTheDocument() + }) + + it('should not render an Operator update badge when operation is CLUSTER', () => { + renderWithProviders() + + expect(screen.queryByText('Operator update')).not.toBeInTheDocument() + }) + it('should trigger scroll up on click', async () => { const { userEvent } = renderWithProviders() const scrollUpButton = screen.getByTestId('scroll-up-button') diff --git a/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.tsx b/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.tsx index c5d67642b6e..4a94246eb88 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-logs/cluster-header-logs/cluster-header-logs.tsx @@ -2,6 +2,7 @@ import download from 'downloadjs' import posthog from 'posthog-js' import { type Cluster, + ClusterDeploymentHistoryOperationEnum, type ClusterLogs, type ClusterStatus, type DeploymentHistoryActionStatus, @@ -9,7 +10,7 @@ import { } 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' @@ -35,6 +36,7 @@ export interface ClusterHeaderLogsProps { triggeredBy?: string | null actionStatus?: DeploymentHistoryActionStatus totalDuration?: string | null + operation?: ClusterDeploymentHistoryOperationEnum } export function ClusterHeaderLogs({ @@ -49,6 +51,7 @@ export function ClusterHeaderLogs({ triggeredBy, actionStatus, totalDuration, + operation, }: ClusterHeaderLogsProps) { const { setDevopsCopilotOpen, sendMessageRef } = useContext(DevopsCopilotContext) @@ -90,6 +93,11 @@ export function ClusterHeaderLogs({ )} + {operation === ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE && ( + + Operator update + + )} {createdAt && ( <> { it('returns undefined without status details', () => { @@ -18,3 +20,34 @@ describe('getTriggerAction', () => { expect(getTriggerAction({})).toBe('UNKNOWN') }) }) + +describe('DeploymentAction', () => { + it('renders the action label derived from the status', () => { + renderWithProviders() + + expect(screen.getByText('Deploy')).toBeInTheDocument() + }) + + it('renders the Operator action with a screen-reader description for an Operator update', () => { + const { container } = renderWithProviders( + + ) + + 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( + + ) + + expect(screen.getByText('Deploy')).not.toHaveAttribute('aria-hidden') + expect(screen.queryByText('Operator update')).not.toBeInTheDocument() + expect(container.querySelector('.fa-rocket')).toBeInTheDocument() + }) +}) diff --git a/libs/shared/ui/src/lib/components/deployment-action/deployment-action.tsx b/libs/shared/ui/src/lib/components/deployment-action/deployment-action.tsx index 21c5181e20c..24e883dc413 100644 --- a/libs/shared/ui/src/lib/components/deployment-action/deployment-action.tsx +++ b/libs/shared/ui/src/lib/components/deployment-action/deployment-action.tsx @@ -1,4 +1,5 @@ import { + ClusterDeploymentHistoryOperationEnum, type ClusterStateEnum, type DeploymentHistoryTriggerAction, type ServiceActionEnum, @@ -137,6 +138,15 @@ export const getDeploymentAction = ( .exhaustive() } +const getDeploymentOperationAction = (operation?: ClusterDeploymentHistoryOperationEnum) => + operation === ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE + ? { + status: 'Operator', + description: 'Operator update', + icon: , + } + : undefined + export const getTriggerAction = (statusDetails?: { action?: ServiceActionEnum sub_action?: ServiceSubActionEnum @@ -148,6 +158,7 @@ export const getTriggerAction = (statusDetails?: { export const DeploymentAction = ({ status, + operation, className, iconClassName, textClassName, @@ -161,17 +172,22 @@ export const DeploymentAction = ({ | StageStatusEnum | StepMetricStatusEnum | undefined + operation?: ClusterDeploymentHistoryOperationEnum className?: string iconClassName?: string textClassName?: string }) => { - const action = getDeploymentAction(status) + const operationAction = getDeploymentOperationAction(operation) + const action = operationAction ?? getDeploymentAction(status) if (!status || !action) return null return (
{action.icon} - {action.status} + + {action.status} + + {operationAction && {operationAction.description}}
) } diff --git a/package.json b/package.json index 9cda0d0263e..26248dcb196 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "mermaid": "11.6.0", "monaco-editor": "0.53.0", "posthog-js": "1.345.1", - "qovery-typescript-axios": "1.1.1004", + "qovery-typescript-axios": "1.1.1007", "react": "19.3.0", "react-country-flag": "3.0.2", "react-datepicker": "4.12.0", diff --git a/yarn.lock b/yarn.lock index c8d85d5dfea..6ece0fd2fff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6285,7 +6285,7 @@ __metadata: prettier: 3.2.5 prettier-plugin-tailwindcss: 0.5.14 pretty-quick: 4.0.0 - qovery-typescript-axios: 1.1.1004 + qovery-typescript-axios: 1.1.1007 qovery-ws-typescript-axios: 0.1.666 react: 19.3.0 react-country-flag: 3.0.2 @@ -24656,12 +24656,12 @@ __metadata: languageName: node linkType: hard -"qovery-typescript-axios@npm:1.1.1004": - version: 1.1.1004 - resolution: "qovery-typescript-axios@npm:1.1.1004" +"qovery-typescript-axios@npm:1.1.1007": + version: 1.1.1007 + resolution: "qovery-typescript-axios@npm:1.1.1007" dependencies: axios: 1.20.0 - checksum: 99fca71abb55689b534cb7c3874deabb11fe6c20a564d7bd4b03d034f5a2a37c4daefbb568b46b468141b7ad7a858daae7e1013516fa8ba272046ba9ba635d73 + checksum: f8662f1567d11edcbe0119c2db44ceeb0b1f0181517b31ca70c89173872dde9fde0f07087b48cbd0d81372c4399675191b520378e9021539ecca100182a5bba8 languageName: node linkType: hard