diff options
author | Damien Urruty <damien.urruty@sonarsource.com> | 2024-10-09 10:23:33 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2024-10-22 20:03:09 +0000 |
commit | 37d9d2491a688ba82b5c839a2279015826c9a28b (patch) | |
tree | 9686cd24104046579a97132e4f869fd3a5f8919c | |
parent | ba001fce190b08431d8eb6e4e16153f7831088c0 (diff) | |
download | sonarqube-37d9d2491a688ba82b5c839a2279015826c9a28b.tar.gz sonarqube-37d9d2491a688ba82b5c839a2279015826c9a28b.zip |
CODEFIX-75 Add a service connection test button in the admin section
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<SuggestedFix> { return axiosToCatch.post<SuggestedFix>('/api/v2/fix-suggestions/ai-suggestions', data); } @@ -36,3 +47,7 @@ export function getSuggestions(data: FixParam): Promise<SuggestedFix> { export function getFixSuggestionsIssues(data: FixParam): Promise<AiIssue> { return axiosToCatch.get(`/api/v2/fix-suggestions/issues/${data.issueId}`); } + +export function checkSuggestionServiceStatus(): Promise<SuggestionServiceStatusCheckResponse> { + 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<T>(response: T): Promise<T> { 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<Props>) { 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<Props>) { return ( <div className="sw-flex"> - <div className="sw-flex-1 sw-p-6"> + <div className="sw-flex-grow sw-p-6"> <Title className="sw-heading-md sw-mb-6">{translate('property.codefix.admin.title')}</Title> <PromotedSection content={ @@ -135,8 +165,145 @@ function CodeFixAdmin({ hasFeature }: Readonly<Props>) { </div> )} </div> + <div className="sw-flex-col sw-w-abs-600 sw-p-6"> + <HighlightedSection className="sw-items-start"> + <Title className="sw-heading-sm sw-mb-6"> + {translate('property.codefix.admin.serviceCheck.title')} + </Title> + <p>{translate('property.codefix.admin.serviceCheck.description1')}</p> + <p className="sw-mt-4">{translate('property.codefix.admin.serviceCheck.description2')}</p> + <Button + className="sw-mt-4" + variety={ButtonVariety.Default} + onClick={() => checkService()} + isDisabled={isPending} + > + {translate('property.codefix.admin.serviceCheck.action')} + </Button> + {!isIdle && ( + <div> + <BasicSeparator className="sw-my-4" /> + <ServiceCheckResultView data={data} error={error} status={status} /> + </div> + )} + </HighlightedSection> + </div> + </div> + ); +} + +interface ServiceCheckResultViewProps { + data: SuggestionServiceStatusCheckResponse | undefined; + error: AxiosError | null; + status: MutationStatus; +} + +function ServiceCheckResultView({ data, error, status }: Readonly<ServiceCheckResultViewProps>) { + switch (status) { + case 'pending': + return <Spinner label={translate('property.codefix.admin.serviceCheck.spinner.label')} />; + case 'error': + return ( + <ErrorMessage + text={`${translate('property.codefix.admin.serviceCheck.result.requestError')} ${error?.status ?? 'No status'}`} + /> + ); + 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 ( + <SuccessMessage text={translate('property.codefix.admin.serviceCheck.result.success')} /> + ); + case 'TIMEOUT': + case 'CONNECTION_ERROR': + return ( + <div className="sw-flex"> + <IconError className="sw-mr-1" color="echoes-color-icon-danger" /> + <div className="sw-flex-col"> + <ErrorLabel + text={translate('property.codefix.admin.serviceCheck.result.unresponsive.message')} + /> + <p className="sw-mt-4"> + <ErrorLabel + text={translate( + 'property.codefix.admin.serviceCheck.result.unresponsive.causes.title', + )} + /> + </p> + <UnorderedList className="sw-ml-8" ticks> + <ErrorListItem className="sw-mb-2"> + <ErrorLabel + text={translate( + 'property.codefix.admin.serviceCheck.result.unresponsive.causes.1', + )} + /> + </ErrorListItem> + <ErrorListItem> + <ErrorLabel + text={translate( + 'property.codefix.admin.serviceCheck.result.unresponsive.causes.2', + )} + /> + </ErrorListItem> + </UnorderedList> + </div> + </div> + ); + case 'UNAUTHORIZED': + return ( + <ErrorMessage text={translate('property.codefix.admin.serviceCheck.result.unauthorized')} /> + ); + case 'SERVICE_ERROR': + return ( + <ErrorMessage text={translate('property.codefix.admin.serviceCheck.result.serviceError')} /> + ); + default: + return ( + <ErrorMessage + text={`${translate('property.codefix.admin.serviceCheck.result.unknown')} ${data?.status ?? 'no status returned from the service'}`} + /> + ); + } +} + +function ErrorMessage({ text }: Readonly<TextProps>) { + return ( + <div className="sw-flex"> + <IconError className="sw-mr-1" color="echoes-color-icon-danger" /> + <ErrorLabel text={text} /> + </div> + ); +} + +function ErrorLabel({ text }: Readonly<TextProps>) { + return <Text colorOverride="echoes-color-text-danger">{text}</Text>; +} + +function SuccessMessage({ text }: Readonly<TextProps>) { + return ( + <div className="sw-flex"> + <IconCheckCircle className="sw-mr-1" color="echoes-color-icon-success" /> + <Text colorOverride="echoes-color-text-success">{text}</Text> </div> ); } +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<AdditionalCategoryComponentProps> = {}, 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<P extends { issue: Issue }>( return <Component aiSuggestionAvailable={data?.aiSuggestion === 'AVAILABLE'} {...props} />; }; } + +export function useCheckServiceMutation() { + return useMutation<SuggestionServiceStatusCheckResponse, AxiosError>({ + 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: #------------------------------------------------------------------------------ # |