From 37d9d2491a688ba82b5c839a2279015826c9a28b Mon Sep 17 00:00:00 2001 From: Damien Urruty Date: Wed, 9 Oct 2024 10:23:33 +0200 Subject: [PATCH] CODEFIX-75 Add a service connection test button in the admin section --- .../src/main/js/api/fix-suggestions.ts | 15 ++ .../main/js/api/mocks/FixIssueServiceMock.ts | 25 ++- .../src/main/js/apps/issues/test-utils.tsx | 4 +- .../apps/settings/components/CodeFixAdmin.tsx | 175 +++++++++++++++++- .../components/__tests__/CodeFixAdmin-it.tsx | 92 ++++++++- .../src/main/js/queries/fix-suggestions.tsx | 16 +- .../resources/org/sonar/l10n/core.properties | 14 ++ 7 files changed, 331 insertions(+), 10 deletions(-) diff --git a/server/sonar-web/src/main/js/api/fix-suggestions.ts b/server/sonar-web/src/main/js/api/fix-suggestions.ts index f4e582f343f..e0a7049abc2 100644 --- a/server/sonar-web/src/main/js/api/fix-suggestions.ts +++ b/server/sonar-web/src/main/js/api/fix-suggestions.ts @@ -29,6 +29,17 @@ export interface AiIssue { id: string; } +export type SuggestionServiceStatus = + | 'SUCCESS' + | 'TIMEOUT' + | 'UNAUTHORIZED' + | 'CONNECTION_ERROR' + | 'SERVICE_ERROR'; + +export interface SuggestionServiceStatusCheckResponse { + status: SuggestionServiceStatus; +} + export function getSuggestions(data: FixParam): Promise { return axiosToCatch.post('/api/v2/fix-suggestions/ai-suggestions', data); } @@ -36,3 +47,7 @@ export function getSuggestions(data: FixParam): Promise { export function getFixSuggestionsIssues(data: FixParam): Promise { return axiosToCatch.get(`/api/v2/fix-suggestions/issues/${data.issueId}`); } + +export function checkSuggestionServiceStatus(): Promise { + return axiosToCatch.post(`/api/v2/fix-suggestions/service-status-checks`); +} diff --git a/server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts index ce7da44af52..56520b2c040 100644 --- a/server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts @@ -18,11 +18,20 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { cloneDeep } from 'lodash'; -import { FixParam, getFixSuggestionsIssues, getSuggestions } from '../fix-suggestions'; +import { + checkSuggestionServiceStatus, + FixParam, + getFixSuggestionsIssues, + getSuggestions, + SuggestionServiceStatus, + SuggestionServiceStatusCheckResponse, +} from '../fix-suggestions'; import { ISSUE_101, ISSUE_1101 } from './data/ids'; jest.mock('../fix-suggestions'); +export type MockSuggestionServiceStatus = SuggestionServiceStatus | 'WTF' | undefined; + export default class FixIssueServiceMock { fixSuggestion = { id: '70b14d4c-d302-4979-9121-06ac7d563c5c', @@ -38,9 +47,12 @@ export default class FixIssueServiceMock { ], }; + serviceStatus: MockSuggestionServiceStatus = 'SUCCESS'; + constructor() { jest.mocked(getSuggestions).mockImplementation(this.handleGetFixSuggestion); jest.mocked(getFixSuggestionsIssues).mockImplementation(this.handleGetFixSuggestionsIssues); + jest.mocked(checkSuggestionServiceStatus).mockImplementation(this.handleCheckService); } handleGetFixSuggestionsIssues = (data: FixParam) => { @@ -57,6 +69,13 @@ export default class FixIssueServiceMock { return this.reply(this.fixSuggestion); }; + handleCheckService = () => { + if (this.serviceStatus) { + return this.reply({ status: this.serviceStatus } as SuggestionServiceStatusCheckResponse); + } + return Promise.reject({ error: { msg: 'Error' } }); + }; + reply(response: T): Promise { return new Promise((resolve) => { setTimeout(() => { @@ -64,4 +83,8 @@ export default class FixIssueServiceMock { }, 10); }); } + + setServiceStatus(status: MockSuggestionServiceStatus) { + this.serviceStatus = status; + } } diff --git a/server/sonar-web/src/main/js/apps/issues/test-utils.tsx b/server/sonar-web/src/main/js/apps/issues/test-utils.tsx index d6e418a435e..ff9963c4461 100644 --- a/server/sonar-web/src/main/js/apps/issues/test-utils.tsx +++ b/server/sonar-web/src/main/js/apps/issues/test-utils.tsx @@ -50,14 +50,14 @@ export const cveHandler = new CveServiceMock(); export const componentsHandler = new ComponentsServiceMock(); export const sourcesHandler = new SourcesServiceMock(); export const branchHandler = new BranchesServiceMock(); -export const fixIssueHanlder = new FixIssueServiceMock(); +export const fixIssueHandler = new FixIssueServiceMock(); export const settingsHandler = new SettingsServiceMock(); export const ui = { loading: byText('issues.loading_issues'), fixGenerated: byText('issues.code_fix.fix_is_being_generated'), noFixAvailable: byText('issues.code_fix.something_went_wrong'), - suggestedExplanation: byText(fixIssueHanlder.fixSuggestion.explanation), + suggestedExplanation: byText(fixIssueHandler.fixSuggestion.explanation), issuePageHeadering: byRole('heading', { level: 1, name: 'issues.page' }), issueItemAction1: byRole('link', { name: 'Issue with no location message' }), issueItemAction2: byRole('link', { name: 'FlowIssue' }), diff --git a/server/sonar-web/src/main/js/apps/settings/components/CodeFixAdmin.tsx b/server/sonar-web/src/main/js/apps/settings/components/CodeFixAdmin.tsx index f2cc6a98353..570507d54fb 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/CodeFixAdmin.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/CodeFixAdmin.tsx @@ -17,16 +17,38 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { Button, ButtonVariety, Checkbox, LinkStandalone } from '@sonarsource/echoes-react'; -import { BasicSeparator, Title } from 'design-system'; +import styled from '@emotion/styled'; +import { + Button, + ButtonVariety, + Checkbox, + IconCheckCircle, + IconError, + LinkStandalone, + Spinner, + Text, +} from '@sonarsource/echoes-react'; +import { MutationStatus } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { + BasicSeparator, + HighlightedSection, + themeColor, + Title, + UnorderedList, +} from 'design-system'; import React, { useEffect } from 'react'; import { FormattedMessage } from 'react-intl'; +import { SuggestionServiceStatusCheckResponse } from '../../../api/fix-suggestions'; import withAvailableFeatures, { WithAvailableFeaturesProps, } from '../../../app/components/available-features/withAvailableFeatures'; import { translate } from '../../../helpers/l10n'; import { getAiCodeFixTermsOfServiceUrl } from '../../../helpers/urls'; -import { useRemoveCodeSuggestionsCache } from '../../../queries/fix-suggestions'; +import { + useCheckServiceMutation, + useRemoveCodeSuggestionsCache, +} from '../../../queries/fix-suggestions'; import { useGetValueQuery, useSaveSimpleValueMutation } from '../../../queries/settings'; import { Feature } from '../../../types/features'; import { SettingsKey } from '../../../types/settings'; @@ -49,6 +71,14 @@ function CodeFixAdmin({ hasFeature }: Readonly) { const [enableCodeFix, setEnableCodeFix] = React.useState(isCodeFixEnabled); const [acceptedTerms, setAcceptedTerms] = React.useState(false); + const { + mutate: checkService, + isIdle, + isPending, + status, + error, + data, + } = useCheckServiceMutation(); const isValueChanged = enableCodeFix !== isCodeFixEnabled; useEffect(() => { @@ -75,7 +105,7 @@ function CodeFixAdmin({ hasFeature }: Readonly) { return (
-
+
{translate('property.codefix.admin.title')} ) {
)}
+
+ + + {translate('property.codefix.admin.serviceCheck.title')} + +

{translate('property.codefix.admin.serviceCheck.description1')}

+

{translate('property.codefix.admin.serviceCheck.description2')}

+ + {!isIdle && ( +
+ + +
+ )} +
+
+
+ ); +} + +interface ServiceCheckResultViewProps { + data: SuggestionServiceStatusCheckResponse | undefined; + error: AxiosError | null; + status: MutationStatus; +} + +function ServiceCheckResultView({ data, error, status }: Readonly) { + switch (status) { + case 'pending': + return ; + case 'error': + return ( + + ); + case 'success': + return ServiceCheckValidResponseView(data); + } + // normally unreachable + throw Error(`Unexpected response from the service status check, received ${status}`); +} + +function ServiceCheckValidResponseView(data: SuggestionServiceStatusCheckResponse | undefined) { + switch (data?.status) { + case 'SUCCESS': + return ( + + ); + case 'TIMEOUT': + case 'CONNECTION_ERROR': + return ( +
+ +
+ +

+ +

+ + + + + + + + +
+
+ ); + case 'UNAUTHORIZED': + return ( + + ); + case 'SERVICE_ERROR': + return ( + + ); + default: + return ( + + ); + } +} + +function ErrorMessage({ text }: Readonly) { + return ( +
+ + +
+ ); +} + +function ErrorLabel({ text }: Readonly) { + return {text}; +} + +function SuccessMessage({ text }: Readonly) { + return ( +
+ + {text}
); } +const ErrorListItem = styled.li` + ::marker { + color: ${themeColor('errorText')}; + } +`; + +interface TextProps { + /** The text to display inside the component */ + text: string; +} + export default withAvailableFeatures(CodeFixAdmin); diff --git a/server/sonar-web/src/main/js/apps/settings/components/__tests__/CodeFixAdmin-it.tsx b/server/sonar-web/src/main/js/apps/settings/components/__tests__/CodeFixAdmin-it.tsx index 735c3b2e109..447c7e9421b 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/__tests__/CodeFixAdmin-it.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/__tests__/CodeFixAdmin-it.tsx @@ -17,11 +17,12 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { waitFor } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { uniq } from 'lodash'; import * as React from 'react'; import { byRole } from '~sonar-aligned/helpers/testSelector'; +import FixIssueServiceMock from '../../../../api/mocks/FixIssueServiceMock'; import SettingsServiceMock, { DEFAULT_DEFINITIONS_MOCK, } from '../../../../api/mocks/SettingsServiceMock'; @@ -34,10 +35,12 @@ import { AdditionalCategoryComponentProps } from '../AdditionalCategories'; import CodeFixAdmin from '../CodeFixAdmin'; let settingServiceMock: SettingsServiceMock; +let fixIssueServiceMock: FixIssueServiceMock; beforeAll(() => { settingServiceMock = new SettingsServiceMock(); settingServiceMock.setDefinitions(definitions); + fixIssueServiceMock = new FixIssueServiceMock(); }); afterEach(() => { @@ -51,6 +54,9 @@ const ui = { name: 'property.codefix.admin.terms property.codefix.admin.acceptTerm.terms', }), saveButton: byRole('button', { name: 'save' }), + checkServiceStatusButton: byRole('button', { + name: 'property.codefix.admin.serviceCheck.action', + }), }; it('should be able to enable the code fix feature', async () => { @@ -86,6 +92,90 @@ it('should be able to disable the code fix feature', async () => { expect(ui.changeCodeFixCheckbox.get()).not.toBeChecked(); }); +it('should display a success message when the service status can be successfully checked', async () => { + fixIssueServiceMock.setServiceStatus('SUCCESS'); + const user = userEvent.setup(); + renderCodeFixAdmin(); + + await user.click(ui.checkServiceStatusButton.get()); + + expect( + await screen.findByText('property.codefix.admin.serviceCheck.result.success'), + ).toBeInTheDocument(); +}); + +it('should display an error message when the service is not responsive', async () => { + fixIssueServiceMock.setServiceStatus('TIMEOUT'); + const user = userEvent.setup(); + renderCodeFixAdmin(); + + await user.click(ui.checkServiceStatusButton.get()); + + expect( + await screen.findByText('property.codefix.admin.serviceCheck.result.unresponsive.message'), + ).toBeInTheDocument(); +}); + +it('should display an error message when there is a connection error with the service', async () => { + fixIssueServiceMock.setServiceStatus('CONNECTION_ERROR'); + const user = userEvent.setup(); + renderCodeFixAdmin(); + + await user.click(ui.checkServiceStatusButton.get()); + + expect( + await screen.findByText('property.codefix.admin.serviceCheck.result.unresponsive.message'), + ).toBeInTheDocument(); +}); + +it('should display an error message when the current instance is unauthorized', async () => { + fixIssueServiceMock.setServiceStatus('UNAUTHORIZED'); + const user = userEvent.setup(); + renderCodeFixAdmin(); + + await user.click(ui.checkServiceStatusButton.get()); + + expect( + await screen.findByText('property.codefix.admin.serviceCheck.result.unauthorized'), + ).toBeInTheDocument(); +}); + +it('should display an error message when an error happens at service level', async () => { + fixIssueServiceMock.setServiceStatus('SERVICE_ERROR'); + const user = userEvent.setup(); + renderCodeFixAdmin(); + + await user.click(ui.checkServiceStatusButton.get()); + + expect( + await screen.findByText('property.codefix.admin.serviceCheck.result.serviceError'), + ).toBeInTheDocument(); +}); + +it('should display an error message when the service answers with an unknown status', async () => { + fixIssueServiceMock.setServiceStatus('WTF'); + const user = userEvent.setup(); + renderCodeFixAdmin(); + + await user.click(ui.checkServiceStatusButton.get()); + + expect( + await screen.findByText('property.codefix.admin.serviceCheck.result.unknown WTF'), + ).toBeInTheDocument(); +}); + +it('should display an error message when the backend answers with an error', async () => { + fixIssueServiceMock.setServiceStatus(undefined); + const user = userEvent.setup(); + renderCodeFixAdmin(); + + await user.click(ui.checkServiceStatusButton.get()); + + expect( + await screen.findByText('property.codefix.admin.serviceCheck.result.requestError No status'), + ).toBeInTheDocument(); +}); + function renderCodeFixAdmin( overrides: Partial = {}, features?: Feature[], diff --git a/server/sonar-web/src/main/js/queries/fix-suggestions.tsx b/server/sonar-web/src/main/js/queries/fix-suggestions.tsx index 6459cdd701a..d33a5dea335 100644 --- a/server/sonar-web/src/main/js/queries/fix-suggestions.tsx +++ b/server/sonar-web/src/main/js/queries/fix-suggestions.tsx @@ -17,10 +17,16 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; import { some } from 'lodash'; import React, { useContext } from 'react'; -import { getFixSuggestionsIssues, getSuggestions } from '../api/fix-suggestions'; +import { + checkSuggestionServiceStatus, + getFixSuggestionsIssues, + getSuggestions, + SuggestionServiceStatusCheckResponse, +} from '../api/fix-suggestions'; import { useAvailableFeatures } from '../app/components/available-features/withAvailableFeatures'; import { CurrentUserContext } from '../app/components/current-user/CurrentUserContext'; import { Feature } from '../types/features'; @@ -181,3 +187,9 @@ export function withUseGetFixSuggestionsIssues

( return ; }; } + +export function useCheckServiceMutation() { + return useMutation({ + mutationFn: checkSuggestionServiceStatus, + }); +} diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index b0e96baa0be..c6d7a3a53ed 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -1920,6 +1920,20 @@ property.codefix.admin.acceptTerm.terms=AI CodeFix Terms property.codefix.admin.promoted_section.title=Free - early access feature property.codefix.admin.promoted_section.content1=This no cost trial is offered to you at Sonar’s discretion. Sonar can decide to stop this trial anytime. property.codefix.admin.promoted_section.content2=At the end of the trial, this feature will be deactivated and your choice to “enable AI CodeFix” below will be ignored. Your organisation will not be charged. +property.codefix.admin.serviceCheck.title=Test the AI CodeFix service +property.codefix.admin.serviceCheck.description1=Make sure this SonarQube instance can communicate with the AI CodeFix service, which requires network connectivity to function. +property.codefix.admin.serviceCheck.description2=This test is free and should only take a few seconds. +property.codefix.admin.serviceCheck.action=Test AI CodeFix service +property.codefix.admin.serviceCheck.spinner.label=Waiting for AI CodeFix service to respond... +property.codefix.admin.serviceCheck.result.success=The AI CodeFix service responded successfully. +property.codefix.admin.serviceCheck.result.unresponsive.message=The AI CodeFix service does not respond or is not reachable. +property.codefix.admin.serviceCheck.result.unresponsive.causes.title=Here are some possible causes of this error: +property.codefix.admin.serviceCheck.result.unresponsive.causes.1=The network may not be properly configured on this SonarQube instance. Please check the firewall and connectivity settings. +property.codefix.admin.serviceCheck.result.unresponsive.causes.2=The AI CodeFix service may be down. +property.codefix.admin.serviceCheck.result.requestError=Error checking the AI CodeFix service: +property.codefix.admin.serviceCheck.result.serviceError=The AI CodeFix service is reachable but returned an error. Please try again later. +property.codefix.admin.serviceCheck.result.unauthorized=This SonarQube instance is not allowed to use AI CodeFix. +property.codefix.admin.serviceCheck.result.unknown=The AI CodeFix service returned an unexpected message: #------------------------------------------------------------------------------ # -- 2.39.5