aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDamien Urruty <damien.urruty@sonarsource.com>2024-10-09 10:23:33 +0200
committersonartech <sonartech@sonarsource.com>2024-10-22 20:03:09 +0000
commit37d9d2491a688ba82b5c839a2279015826c9a28b (patch)
tree9686cd24104046579a97132e4f869fd3a5f8919c
parentba001fce190b08431d8eb6e4e16153f7831088c0 (diff)
downloadsonarqube-37d9d2491a688ba82b5c839a2279015826c9a28b.tar.gz
sonarqube-37d9d2491a688ba82b5c839a2279015826c9a28b.zip
CODEFIX-75 Add a service connection test button in the admin section
-rw-r--r--server/sonar-web/src/main/js/api/fix-suggestions.ts15
-rw-r--r--server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts25
-rw-r--r--server/sonar-web/src/main/js/apps/issues/test-utils.tsx4
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/CodeFixAdmin.tsx175
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/__tests__/CodeFixAdmin-it.tsx92
-rw-r--r--server/sonar-web/src/main/js/queries/fix-suggestions.tsx16
-rw-r--r--sonar-core/src/main/resources/org/sonar/l10n/core.properties14
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:
#------------------------------------------------------------------------------
#