]> source.dussan.org Git - sonarqube.git/commitdiff
CODEFIX-75 Add a service connection test button in the admin section
authorDamien Urruty <damien.urruty@sonarsource.com>
Wed, 9 Oct 2024 08:23:33 +0000 (10:23 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 22 Oct 2024 20:03:09 +0000 (20:03 +0000)
server/sonar-web/src/main/js/api/fix-suggestions.ts
server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts
server/sonar-web/src/main/js/apps/issues/test-utils.tsx
server/sonar-web/src/main/js/apps/settings/components/CodeFixAdmin.tsx
server/sonar-web/src/main/js/apps/settings/components/__tests__/CodeFixAdmin-it.tsx
server/sonar-web/src/main/js/queries/fix-suggestions.tsx
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index f4e582f343f0a5c5e8170c3bf8752e7b0d8e182f..e0a7049abc2ea73e005b09af92ce2078a1970b7c 100644 (file)
@@ -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`);
+}
index ce7da44af52422e402315a945063ce7553f23ca5..56520b2c0403d14316989916932c139e8df9dd0d 100644 (file)
  * 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;
+  }
 }
index d6e418a435ec7a906a8130b06f4dbbf2246d095f..ff9963c4461b6f68489872f6a4c48e6405bd9830 100644 (file)
@@ -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' }),
index f2cc6a9835322cc4c3452fefb8d26257676e6829..570507d54fb07ade226075f14ea0a29dcec0f9cd 100644 (file)
  * 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);
index 735c3b2e109a88fb10c179effc902354914480b2..447c7e9421b8abcfa46114eacb3ab20a1d9cd2f3 100644 (file)
  * 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[],
index 6459cdd701a3f6175ca34ff8f1d10cafb5575d31..d33a5dea33539cc079ba39ebcea5ea44eabe952e 100644 (file)
  * 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,
+  });
+}
index b0e96baa0bee7db9f543195c7fcf80f4f1aa4c1f..c6d7a3a53ed5745f1ef764fbaaf8cd5610d7454c 100644 (file)
@@ -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: 
 
 #------------------------------------------------------------------------------
 #