From 358a277756263f974db5065fb8612ce1b7520c90 Mon Sep 17 00:00:00 2001 From: Pierre Gerbelot Date: Tue, 13 Oct 2026 11:06:40 +0200 Subject: [PATCH 1/2] feat(clusters): label Operator updates in deployment history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump qovery-typescript-axios to 1.1.1007 for the new `operation` field on ClusterDeploymentHistory. An Operator update now reads "Operator" (hover tooltip "Operator update") instead of "Deploy" in the cluster deployment list and the last-deployment section, and gets an "Operator update" badge in the deployment logs header. The last-deployment section only trusts `operation` when the history entry matches the cluster status's last execution. The audit logs suffix cluster events whose change carries `operation: OPERATOR_UPDATE` with "· Operator update", with the full label in a tooltip since the event column truncates. DeploymentAction gains an optional `label` override so the Operator action reuses its markup. The Status cell's action group can now wrap so badges never overlap the status icon. --- .../deployments/logs/$deploymentId.tsx | 1 + .../src/lib/row-event/row-event.spec.tsx | 101 ++++++++++++++ .../feature/src/lib/row-event/row-event.tsx | 44 ++++-- .../cluster-deployment-action.spec.tsx | 27 ++++ .../cluster-deployment-action.tsx | 22 +++ .../cluster-deployment-list.spec.tsx | 42 +++++- .../cluster-deployment-list.tsx | 8 +- .../cluster-last-deployment-section.spec.tsx | 126 +++++++++++++++--- .../cluster-last-deployment-section.tsx | 24 ++-- .../cluster-header-logs.spec.tsx | 16 ++- .../cluster-header-logs.tsx | 10 +- .../deployment-action.spec.tsx | 18 ++- .../deployment-action/deployment-action.tsx | 4 +- package.json | 2 +- yarn.lock | 10 +- 15 files changed, 400 insertions(+), 55 deletions(-) create mode 100644 libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-action.spec.tsx create mode 100644 libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-action.tsx 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/audit-logs/feature/src/lib/row-event/row-event.spec.tsx b/libs/domains/audit-logs/feature/src/lib/row-event/row-event.spec.tsx index 765cbda2d95..4642791b410 100644 --- a/libs/domains/audit-logs/feature/src/lib/row-event/row-event.spec.tsx +++ b/libs/domains/audit-logs/feature/src/lib/row-event/row-event.spec.tsx @@ -127,6 +127,107 @@ describe('RowEvent', () => { } ) + it('should render an empty event label when the event type is missing', () => { + renderWithProviders( + + ) + + expect(screen.getByTestId('tag')).toHaveTextContent(/^$/) + }) + + const operatorUpdateChange = JSON.stringify({ + operator: 'Jane Admin', + operation: 'OPERATOR_UPDATE', + execution_id: 'execution-1', + chart_version: null, + image_version: 'v9.999.0', + }) + + it.each([ + [OrganizationEventType.MAINTENANCE, 'Maintenance · Operator update'], + [OrganizationEventType.TRIGGER_DEPLOY, 'Trigger Deploy · Operator update'], + ])('should label a %s cluster event as an Operator update', (eventType, label) => { + renderWithProviders( + + ) + + expect(screen.getByTestId('tag')).toHaveTextContent(label) + }) + + const operatorUpdateEvent: OrganizationEventResponse = { + ...props.event, + event_type: OrganizationEventType.MAINTENANCE, + target_type: OrganizationEventTargetType.CLUSTER, + sub_target_type: null, + change: operatorUpdateChange, + } + + it('should reveal the full Operator update label on hover', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.hover(screen.getByText('Maintenance')) + + expect(await screen.findByRole('tooltip')).toHaveTextContent('Maintenance · Operator update') + }) + + it('should reveal the full Operator update label on keyboard focus', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.tab() + + expect(screen.getByText('Maintenance').parentElement).toHaveFocus() + expect(await screen.findByRole('tooltip')).toHaveTextContent('Maintenance · Operator update') + }) + + it.each([ + ['a cluster operation', JSON.stringify({ operation: 'CLUSTER' })], + ['a change without operation', JSON.stringify({ execution_id: 'execution-1' })], + ['a non-object change', JSON.stringify('OPERATOR_UPDATE')], + ['a null change', 'null'], + ['an invalid JSON change', '{"operation": "OPERATOR_UPDATE"'], + ])('should not label a cluster event with %s as an Operator update', (_, change) => { + renderWithProviders( + + ) + + expect(screen.getByTestId('tag')).toHaveTextContent(/^Maintenance$/) + }) + + it('should not label a non-cluster event as an Operator update', () => { + renderWithProviders( + + ) + + expect(screen.getByTestId('tag')).toHaveTextContent(/^Trigger Deploy$/) + }) + it('should render Agent task for the agentic workflow target type', () => { renderWithProviders( { return eventName.split('_').map(upperCaseFirstLetter).join(' ') } +const isOperatorUpdateEvent = ({ target_type, change }: OrganizationEventResponse) => { + if (target_type !== OrganizationEventTargetType.CLUSTER || !change) return false + + try { + const parsedChange: unknown = JSON.parse(change) + return ( + typeof parsedChange === 'object' && + parsedChange !== null && + 'operation' in parsedChange && + parsedChange.operation === ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE + ) + } catch { + return false + } +} + const serviceOverviewUrl = (organizationId: string, projectId: string, environmentId: string, serviceId: string) => `/organization/${organizationId}/project/${projectId}/environment/${environmentId}/service/${serviceId}/overview` @@ -128,6 +145,9 @@ export function RowEvent(props: RowEventProps) { return {target_name} } + const eventName = formatEventName(event.event_type ?? '') + const isOperatorUpdate = isOperatorUpdateEvent(event) + const isEventTypeFailed = event.event_type === OrganizationEventType.ACCESS_DENIED || event.event_type?.toLowerCase().includes('fail') @@ -233,17 +253,21 @@ export function RowEvent(props: RowEventProps) {
- - - {formatEventName(event.event_type ?? '')} + + + {eventName} + {event.sub_target_type && ( + + {' '} + : {upperCaseFirstLetter(event.sub_target_type)?.replace(/_/g, ' ')} + + )} + {isOperatorUpdate && · Operator update} - {event.sub_target_type && ( - - {' '} - : {upperCaseFirstLetter(event.sub_target_type)?.replace(/_/g, ' ')} - - )} - +
diff --git a/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-action.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-action.spec.tsx new file mode 100644 index 00000000000..5bff77f2f6b --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-action.spec.tsx @@ -0,0 +1,27 @@ +import { ClusterDeploymentHistoryOperationEnum } from 'qovery-typescript-axios' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { ClusterDeploymentAction } from './cluster-deployment-action' + +describe('ClusterDeploymentAction', () => { + it.each([ClusterDeploymentHistoryOperationEnum.CLUSTER, undefined])( + 'renders the deployment action when operation is %s', + (operation) => { + renderWithProviders() + + expect(screen.getByText('Deploy')).toBeInTheDocument() + expect(screen.queryByText('Operator')).not.toBeInTheDocument() + } + ) + + it('renders the Operator action with a hover tooltip and no tab stop for an Operator update', async () => { + const { userEvent } = renderWithProviders( + + ) + + await userEvent.hover(screen.getByText('Operator')) + + expect(screen.queryByText('Deploy')).not.toBeInTheDocument() + expect(screen.getByText('Operator').closest('[tabindex]')).toBeNull() + expect(await screen.findByRole('tooltip')).toHaveTextContent('Operator update') + }) +}) diff --git a/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-action.tsx b/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-action.tsx new file mode 100644 index 00000000000..8fae82b6b10 --- /dev/null +++ b/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-action.tsx @@ -0,0 +1,22 @@ +import { ClusterDeploymentHistoryOperationEnum } from 'qovery-typescript-axios' +import { type ComponentProps } from 'react' +import { DeploymentAction, Tooltip } from '@qovery/shared/ui' + +export interface ClusterDeploymentActionProps { + status: ComponentProps['status'] + operation?: ClusterDeploymentHistoryOperationEnum +} + +export function ClusterDeploymentAction({ status, operation }: ClusterDeploymentActionProps) { + if (operation !== ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE) { + return + } + + return ( + +
+ +
+
+ ) +} 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..4ce59455023 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,45 @@ describe('ClusterDeploymentList', () => { expect(screen.getByText('Maintenance')).toBeInTheDocument() }) + it('should render the Operator action with its maintenance badge when operation is OPERATOR_UPDATE', async () => { + mockDeploymentHistory = [ + { + ...defaultDeploymentHistory[0], + operation: ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE, + }, + ] + const { userEvent } = renderWithProviders( + + ) + + await userEvent.hover(screen.getByText('Operator')) + + expect(screen.getByText('Operator')).toBeInTheDocument() + expect(screen.queryByText('Deploy')).not.toBeInTheDocument() + expect(screen.getByText('Maintenance')).toBeInTheDocument() + expect(screen.getByText('Operator').closest('[tabindex]')?.tagName).toBe('TR') + expect(await screen.findByRole('tooltip')).toHaveTextContent('Operator update') + }) + + 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..28b0fbdcd17 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 @@ -19,7 +19,6 @@ import { DevopsCopilotTroubleshootTrigger } from '@qovery/shared/devops-copilot/ import { Badge, CopyToClipboardButtonIcon, - DeploymentAction, EmptyState, Icon, Link, @@ -33,6 +32,7 @@ import { dateFullFormat } from '@qovery/shared/util-dates' import { EventOriginIcon } from '@qovery/shared/util-icons' import { twMerge, upperCaseFirstLetter } from '@qovery/shared/util-js' import { useClusterDeploymentHistory } from '../hooks/use-cluster-deployment-history/use-cluster-deployment-history' +import { ClusterDeploymentAction } from './cluster-deployment-action' import { ClusterDeploymentDurationCell } from './cluster-deployment-duration-cell' const { Table } = TablePrimitives @@ -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,88 @@ 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() + }) + + 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( ( @@ -117,6 +107,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 @@ -170,7 +165,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 +19,18 @@ 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 label override instead of the derived action label', () => { + renderWithProviders() + + expect(screen.getByText('Operator')).toBeInTheDocument() + expect(screen.queryByText('Deploy')).not.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..6331edfaae7 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 @@ -148,6 +148,7 @@ export const getTriggerAction = (statusDetails?: { export const DeploymentAction = ({ status, + label, className, iconClassName, textClassName, @@ -161,6 +162,7 @@ export const DeploymentAction = ({ | StageStatusEnum | StepMetricStatusEnum | undefined + label?: string className?: string iconClassName?: string textClassName?: string @@ -171,7 +173,7 @@ export const DeploymentAction = ({ return (
{action.icon} - {action.status} + {label ?? action.status}
) } 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 From 44e1b6b0075a570d62c980ffa075c9f9faf225a3 Mon Sep 17 00:00:00 2001 From: Pierre Gerbelot Date: Tue, 13 Oct 2026 14:51:05 +0200 Subject: [PATCH 2/2] refactor(clusters): resolve Operator update in DeploymentAction DeploymentAction takes the cluster deployment `operation` and resolves the label, icon and screen-reader description in one place: an Operator update reads "Operator" with the rotate-right icon and "Operator update" as sr-only text. This replaces the ClusterDeploymentAction wrapper, its tooltip and the `label` override. The audit-logs labelling is dropped from this PR; it will come back once the API exposes `operation` on audit events. --- .../src/lib/row-event/row-event.spec.tsx | 101 ------------------ .../feature/src/lib/row-event/row-event.tsx | 44 ++------ .../cluster-deployment-action.spec.tsx | 27 ----- .../cluster-deployment-action.tsx | 22 ---- .../cluster-deployment-list.spec.tsx | 15 ++- .../cluster-deployment-list.tsx | 4 +- .../cluster-last-deployment-section.spec.tsx | 4 + .../cluster-last-deployment-section.tsx | 16 ++- .../deployment-action.spec.tsx | 23 +++- .../deployment-action/deployment-action.tsx | 22 +++- 10 files changed, 74 insertions(+), 204 deletions(-) delete mode 100644 libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-action.spec.tsx delete mode 100644 libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-action.tsx diff --git a/libs/domains/audit-logs/feature/src/lib/row-event/row-event.spec.tsx b/libs/domains/audit-logs/feature/src/lib/row-event/row-event.spec.tsx index 4642791b410..765cbda2d95 100644 --- a/libs/domains/audit-logs/feature/src/lib/row-event/row-event.spec.tsx +++ b/libs/domains/audit-logs/feature/src/lib/row-event/row-event.spec.tsx @@ -127,107 +127,6 @@ describe('RowEvent', () => { } ) - it('should render an empty event label when the event type is missing', () => { - renderWithProviders( - - ) - - expect(screen.getByTestId('tag')).toHaveTextContent(/^$/) - }) - - const operatorUpdateChange = JSON.stringify({ - operator: 'Jane Admin', - operation: 'OPERATOR_UPDATE', - execution_id: 'execution-1', - chart_version: null, - image_version: 'v9.999.0', - }) - - it.each([ - [OrganizationEventType.MAINTENANCE, 'Maintenance · Operator update'], - [OrganizationEventType.TRIGGER_DEPLOY, 'Trigger Deploy · Operator update'], - ])('should label a %s cluster event as an Operator update', (eventType, label) => { - renderWithProviders( - - ) - - expect(screen.getByTestId('tag')).toHaveTextContent(label) - }) - - const operatorUpdateEvent: OrganizationEventResponse = { - ...props.event, - event_type: OrganizationEventType.MAINTENANCE, - target_type: OrganizationEventTargetType.CLUSTER, - sub_target_type: null, - change: operatorUpdateChange, - } - - it('should reveal the full Operator update label on hover', async () => { - const { userEvent } = renderWithProviders() - - await userEvent.hover(screen.getByText('Maintenance')) - - expect(await screen.findByRole('tooltip')).toHaveTextContent('Maintenance · Operator update') - }) - - it('should reveal the full Operator update label on keyboard focus', async () => { - const { userEvent } = renderWithProviders() - - await userEvent.tab() - - expect(screen.getByText('Maintenance').parentElement).toHaveFocus() - expect(await screen.findByRole('tooltip')).toHaveTextContent('Maintenance · Operator update') - }) - - it.each([ - ['a cluster operation', JSON.stringify({ operation: 'CLUSTER' })], - ['a change without operation', JSON.stringify({ execution_id: 'execution-1' })], - ['a non-object change', JSON.stringify('OPERATOR_UPDATE')], - ['a null change', 'null'], - ['an invalid JSON change', '{"operation": "OPERATOR_UPDATE"'], - ])('should not label a cluster event with %s as an Operator update', (_, change) => { - renderWithProviders( - - ) - - expect(screen.getByTestId('tag')).toHaveTextContent(/^Maintenance$/) - }) - - it('should not label a non-cluster event as an Operator update', () => { - renderWithProviders( - - ) - - expect(screen.getByTestId('tag')).toHaveTextContent(/^Trigger Deploy$/) - }) - it('should render Agent task for the agentic workflow target type', () => { renderWithProviders( { return eventName.split('_').map(upperCaseFirstLetter).join(' ') } -const isOperatorUpdateEvent = ({ target_type, change }: OrganizationEventResponse) => { - if (target_type !== OrganizationEventTargetType.CLUSTER || !change) return false - - try { - const parsedChange: unknown = JSON.parse(change) - return ( - typeof parsedChange === 'object' && - parsedChange !== null && - 'operation' in parsedChange && - parsedChange.operation === ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE - ) - } catch { - return false - } -} - const serviceOverviewUrl = (organizationId: string, projectId: string, environmentId: string, serviceId: string) => `/organization/${organizationId}/project/${projectId}/environment/${environmentId}/service/${serviceId}/overview` @@ -145,9 +128,6 @@ export function RowEvent(props: RowEventProps) { return {target_name} } - const eventName = formatEventName(event.event_type ?? '') - const isOperatorUpdate = isOperatorUpdateEvent(event) - const isEventTypeFailed = event.event_type === OrganizationEventType.ACCESS_DENIED || event.event_type?.toLowerCase().includes('fail') @@ -253,21 +233,17 @@ export function RowEvent(props: RowEventProps) {
- - - {eventName} - {event.sub_target_type && ( - - {' '} - : {upperCaseFirstLetter(event.sub_target_type)?.replace(/_/g, ' ')} - - )} - {isOperatorUpdate && · Operator update} + + + {formatEventName(event.event_type ?? '')} - + {event.sub_target_type && ( + + {' '} + : {upperCaseFirstLetter(event.sub_target_type)?.replace(/_/g, ' ')} + + )} +
diff --git a/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-action.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-action.spec.tsx deleted file mode 100644 index 5bff77f2f6b..00000000000 --- a/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-action.spec.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { ClusterDeploymentHistoryOperationEnum } from 'qovery-typescript-axios' -import { renderWithProviders, screen } from '@qovery/shared/util-tests' -import { ClusterDeploymentAction } from './cluster-deployment-action' - -describe('ClusterDeploymentAction', () => { - it.each([ClusterDeploymentHistoryOperationEnum.CLUSTER, undefined])( - 'renders the deployment action when operation is %s', - (operation) => { - renderWithProviders() - - expect(screen.getByText('Deploy')).toBeInTheDocument() - expect(screen.queryByText('Operator')).not.toBeInTheDocument() - } - ) - - it('renders the Operator action with a hover tooltip and no tab stop for an Operator update', async () => { - const { userEvent } = renderWithProviders( - - ) - - await userEvent.hover(screen.getByText('Operator')) - - expect(screen.queryByText('Deploy')).not.toBeInTheDocument() - expect(screen.getByText('Operator').closest('[tabindex]')).toBeNull() - expect(await screen.findByRole('tooltip')).toHaveTextContent('Operator update') - }) -}) diff --git a/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-action.tsx b/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-action.tsx deleted file mode 100644 index 8fae82b6b10..00000000000 --- a/libs/domains/clusters/feature/src/lib/cluster-deployment-list/cluster-deployment-action.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { ClusterDeploymentHistoryOperationEnum } from 'qovery-typescript-axios' -import { type ComponentProps } from 'react' -import { DeploymentAction, Tooltip } from '@qovery/shared/ui' - -export interface ClusterDeploymentActionProps { - status: ComponentProps['status'] - operation?: ClusterDeploymentHistoryOperationEnum -} - -export function ClusterDeploymentAction({ status, operation }: ClusterDeploymentActionProps) { - if (operation !== ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE) { - return - } - - return ( - -
- -
-
- ) -} 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 4ce59455023..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 @@ -68,24 +68,23 @@ describe('ClusterDeploymentList', () => { expect(screen.getByText('Maintenance')).toBeInTheDocument() }) - it('should render the Operator action with its maintenance badge when operation is OPERATOR_UPDATE', async () => { + it('should render the Operator action with its maintenance badge when operation is OPERATOR_UPDATE', () => { mockDeploymentHistory = [ { ...defaultDeploymentHistory[0], operation: ClusterDeploymentHistoryOperationEnum.OPERATOR_UPDATE, }, ] - const { userEvent } = renderWithProviders( - - ) - await userEvent.hover(screen.getByText('Operator')) + renderWithProviders() - expect(screen.getByText('Operator')).toBeInTheDocument() + const action = screen.getByText('Operator').parentElement expect(screen.queryByText('Deploy')).not.toBeInTheDocument() expect(screen.getByText('Maintenance')).toBeInTheDocument() - expect(screen.getByText('Operator').closest('[tabindex]')?.tagName).toBe('TR') - expect(await screen.findByRole('tooltip')).toHaveTextContent('Operator update') + 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', () => { 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 28b0fbdcd17..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 @@ -19,6 +19,7 @@ import { DevopsCopilotTroubleshootTrigger } from '@qovery/shared/devops-copilot/ import { Badge, CopyToClipboardButtonIcon, + DeploymentAction, EmptyState, Icon, Link, @@ -32,7 +33,6 @@ import { dateFullFormat } from '@qovery/shared/util-dates' import { EventOriginIcon } from '@qovery/shared/util-icons' import { twMerge, upperCaseFirstLetter } from '@qovery/shared/util-js' import { useClusterDeploymentHistory } from '../hooks/use-cluster-deployment-history/use-cluster-deployment-history' -import { ClusterDeploymentAction } from './cluster-deployment-action' import { ClusterDeploymentDurationCell } from './cluster-deployment-duration-cell' const { Table } = TablePrimitives @@ -154,7 +154,7 @@ export function ClusterDeploymentList({ organizationId, clusterId }: ClusterDepl return (
- + {data.reason === 'MAINTENANCE' && ( Maintenance diff --git a/libs/domains/clusters/feature/src/lib/cluster-last-deployment-section/cluster-last-deployment-section.spec.tsx b/libs/domains/clusters/feature/src/lib/cluster-last-deployment-section/cluster-last-deployment-section.spec.tsx index f83d4527f27..2dc12f1302f 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-last-deployment-section/cluster-last-deployment-section.spec.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-last-deployment-section/cluster-last-deployment-section.spec.tsx @@ -208,6 +208,10 @@ describe('ClusterLastDeploymentSection', () => { 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', () => { diff --git a/libs/domains/clusters/feature/src/lib/cluster-last-deployment-section/cluster-last-deployment-section.tsx b/libs/domains/clusters/feature/src/lib/cluster-last-deployment-section/cluster-last-deployment-section.tsx index c04e3b06ec4..08baac09e83 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-last-deployment-section/cluster-last-deployment-section.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-last-deployment-section/cluster-last-deployment-section.tsx @@ -8,11 +8,21 @@ import { import { useContext } from 'react' import { match } from 'ts-pattern' import { DevopsCopilotContext } from '@qovery/shared/devops-copilot/context' -import { Badge, EmptyState, Heading, Icon, Link, Section, Skeleton, StatusChip, Tooltip } from '@qovery/shared/ui' +import { + Badge, + DeploymentAction, + EmptyState, + Heading, + Icon, + Link, + Section, + Skeleton, + StatusChip, + Tooltip, +} from '@qovery/shared/ui' import { dateUTCString, timeAgo } from '@qovery/shared/util-dates' import { useIntervalTick } from '@qovery/shared/util-hooks' import { upperCaseFirstLetter } from '@qovery/shared/util-js' -import { ClusterDeploymentAction } from '../cluster-deployment-list/cluster-deployment-action' import { useClusterDeploymentHistory } from '../hooks/use-cluster-deployment-history/use-cluster-deployment-history' const DotSeparator = () => ( @@ -165,7 +175,7 @@ export function ClusterLastDeploymentSection({ >
- diff --git a/libs/shared/ui/src/lib/components/deployment-action/deployment-action.spec.tsx b/libs/shared/ui/src/lib/components/deployment-action/deployment-action.spec.tsx index 2412f9874f4..5bfb893ef23 100644 --- a/libs/shared/ui/src/lib/components/deployment-action/deployment-action.spec.tsx +++ b/libs/shared/ui/src/lib/components/deployment-action/deployment-action.spec.tsx @@ -1,3 +1,4 @@ +import { ClusterDeploymentHistoryOperationEnum } from 'qovery-typescript-axios' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import { DeploymentAction, getTriggerAction } from './deployment-action' @@ -27,10 +28,26 @@ describe('DeploymentAction', () => { expect(screen.getByText('Deploy')).toBeInTheDocument() }) - it('renders the label override instead of the derived action label', () => { - renderWithProviders() + it('renders the Operator action with a screen-reader description for an Operator update', () => { + const { container } = renderWithProviders( + + ) - expect(screen.getByText('Operator')).toBeInTheDocument() + 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 6331edfaae7..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,7 +158,7 @@ export const getTriggerAction = (statusDetails?: { export const DeploymentAction = ({ status, - label, + operation, className, iconClassName, textClassName, @@ -162,18 +172,22 @@ export const DeploymentAction = ({ | StageStatusEnum | StepMetricStatusEnum | undefined - label?: string + 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} - {label ?? action.status} + + {action.status} + + {operationAction && {operationAction.description}}
) }