]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-22141 Show progress bar and estimate on migration page
authorJeremy Davis <jeremy.davis@sonarsource.com>
Fri, 3 May 2024 07:53:07 +0000 (09:53 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 8 May 2024 20:02:44 +0000 (20:02 +0000)
server/sonar-web/src/main/js/api/system.ts
server/sonar-web/src/main/js/apps/maintenance/components/App.tsx
server/sonar-web/src/main/js/apps/maintenance/components/MigrationProgress.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/maintenance/components/__tests__/App-test.tsx
server/sonar-web/src/main/js/types/system.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 9bf9ca49e6fe687bd768e0b92d2c654ff40fcf2b..ba72977355be001ecbcbef340abe463b758d383c 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 axios from 'axios';
 import { throwGlobalError } from '~sonar-aligned/helpers/error';
 import { getJSON } from '~sonar-aligned/helpers/request';
 import { post, postJSON, requestTryAndRepeatUntil } from '../helpers/request';
-import { SystemUpgrade } from '../types/system';
+import { MigrationStatus, MigrationsStatusResponse, SystemUpgrade } from '../types/system';
 import { SysInfoCluster, SysInfoStandalone, SysStatus } from '../types/types';
 
+const MIGRATIONS_STATUS_ENDPOINT = '/api/v2/system/migrations-status';
+
 export function setLogLevel(level: string): Promise<void | Response> {
   return post('/api/system/change_log_level', { level }).catch(throwGlobalError);
 }
@@ -44,18 +47,14 @@ export function getSystemUpgrades(): Promise<{
   return getJSON('/api/system/upgrades');
 }
 
-export function getMigrationStatus(): Promise<{
-  message?: string;
-  startedAt?: string;
-  state: string;
-}> {
-  return getJSON('/api/system/db_migration_status');
+export function getMigrationsStatus() {
+  return axios.get<MigrationsStatusResponse>(MIGRATIONS_STATUS_ENDPOINT);
 }
 
 export function migrateDatabase(): Promise<{
   message?: string;
   startedAt?: string;
-  state: string;
+  state: MigrationStatus;
 }> {
   return postJSON('/api/system/migrate_db');
 }
index be38e085a102c2c5e7b5e4bb0e0a678a67296177..90a89d048fd00cf297196ca9a06e9b1aaf23c4e4 100644 (file)
@@ -24,7 +24,7 @@ import { ButtonPrimary, Card, CenteredLayout, Note, Title } from 'design-system'
 import * as React from 'react';
 import { Helmet } from 'react-helmet-async';
 import { FormattedMessage } from 'react-intl';
-import { getMigrationStatus, getSystemStatus, migrateDatabase } from '../../../api/system';
+import { getMigrationsStatus, getSystemStatus, migrateDatabase } from '../../../api/system';
 import DocumentationLink from '../../../components/common/DocumentationLink';
 import InstanceMessage from '../../../components/common/InstanceMessage';
 import DateFromNow from '../../../components/intl/DateFromNow';
@@ -33,6 +33,8 @@ import { translate } from '../../../helpers/l10n';
 import { getBaseUrl } from '../../../helpers/system';
 import { isDefined } from '../../../helpers/types';
 import { getReturnUrl } from '../../../helpers/urls';
+import { MigrationStatus } from '../../../types/system';
+import { MigrationProgress } from './MigrationProgress';
 
 interface Props {
   location: { query?: { return_to?: string } };
@@ -42,9 +44,14 @@ interface Props {
 interface State {
   message?: string;
   startedAt?: string;
-  state?: string;
-  status?: string;
+  migrationState?: MigrationStatus;
+  systemStatus?: string;
   wasStarting?: boolean;
+  progress?: {
+    completedSteps: number;
+    totalSteps: number;
+    expectedFinishTimestamp: string;
+  };
 }
 
 const DELAY_REDIRECT_PREV_PAGE = 2500;
@@ -76,22 +83,22 @@ export default class App extends React.PureComponent<Props, State> {
         this.setState({
           message: undefined,
           startedAt: undefined,
-          state: undefined,
-          status: 'OFFLINE',
+          migrationState: undefined,
+          systemStatus: 'OFFLINE',
         });
       }
     });
   };
 
   fetchSystemStatus = () => {
-    return getSystemStatus().then(({ status }) => {
+    return getSystemStatus().then(({ status: systemStatus }) => {
       if (this.mounted) {
-        this.setState({ status });
+        this.setState({ systemStatus });
 
-        if (status === 'STARTING') {
+        if (systemStatus === 'STARTING') {
           this.setState({ wasStarting: true });
           this.scheduleRefresh();
-        } else if (status === 'UP') {
+        } else if (systemStatus === 'UP') {
           if (this.state.wasStarting) {
             this.loadPreviousPage();
           }
@@ -103,16 +110,39 @@ export default class App extends React.PureComponent<Props, State> {
   };
 
   fetchMigrationState = () => {
-    return getMigrationStatus().then(({ message, startedAt, state }) => {
-      if (this.mounted) {
-        this.setState({ message, startedAt, state });
-        if (state === 'MIGRATION_SUCCEEDED') {
-          this.loadPreviousPage();
-        } else if (state !== 'NO_MIGRATION') {
-          this.scheduleRefresh();
+    return getMigrationsStatus().then(
+      ({
+        message,
+        startedAt,
+        status: migrationState,
+        completedSteps,
+        totalSteps,
+        expectedFinishTimestamp,
+      }) => {
+        if (this.mounted) {
+          const progress =
+            isDefined(completedSteps) && isDefined(totalSteps) && isDefined(expectedFinishTimestamp)
+              ? {
+                  completedSteps,
+                  totalSteps,
+                  expectedFinishTimestamp,
+                }
+              : undefined;
+
+          this.setState({
+            message,
+            startedAt,
+            migrationState,
+            progress,
+          });
+          if (migrationState === 'MIGRATION_SUCCEEDED') {
+            this.loadPreviousPage();
+          } else if (migrationState !== 'NO_MIGRATION') {
+            this.scheduleRefresh();
+          }
         }
-      }
-    });
+      },
+    );
   };
 
   scheduleRefresh = () => {
@@ -129,7 +159,7 @@ export default class App extends React.PureComponent<Props, State> {
     migrateDatabase().then(
       ({ message, startedAt, state }) => {
         if (this.mounted) {
-          this.setState({ message, startedAt, state });
+          this.setState({ message, startedAt, migrationState: state });
         }
       },
       () => {},
@@ -137,7 +167,7 @@ export default class App extends React.PureComponent<Props, State> {
   };
 
   render() {
-    const { state, status } = this.state;
+    const { migrationState, systemStatus, progress } = this.state;
 
     return (
       <>
@@ -145,7 +175,7 @@ export default class App extends React.PureComponent<Props, State> {
 
         <CenteredLayout className="sw-flex sw-justify-around sw-mt-32" id="bd">
           <Card className="sw-body-sm sw-p-10 sw-w-abs-400" id="nonav">
-            {status === 'OFFLINE' && (
+            {systemStatus === 'OFFLINE' && (
               <>
                 <MaintenanceTitle className="text-danger">
                   <InstanceMessage message={translate('maintenance.is_offline')} />
@@ -163,7 +193,7 @@ export default class App extends React.PureComponent<Props, State> {
               </>
             )}
 
-            {status === 'UP' && (
+            {systemStatus === 'UP' && (
               <>
                 <MaintenanceTitle>
                   <InstanceMessage message={translate('maintenance.is_up')} />
@@ -179,7 +209,7 @@ export default class App extends React.PureComponent<Props, State> {
               </>
             )}
 
-            {status === 'STARTING' && (
+            {systemStatus === 'STARTING' && (
               <>
                 <MaintenanceTitle>
                   <InstanceMessage message={translate('maintenance.is_starting')} />
@@ -191,7 +221,7 @@ export default class App extends React.PureComponent<Props, State> {
               </>
             )}
 
-            {status === 'DOWN' && (
+            {systemStatus === 'DOWN' && (
               <>
                 <MaintenanceTitle className="text-danger">
                   <InstanceMessage message={translate('maintenance.is_down')} />
@@ -207,7 +237,8 @@ export default class App extends React.PureComponent<Props, State> {
               </>
             )}
 
-            {(status === 'DB_MIGRATION_NEEDED' || status === 'DB_MIGRATION_RUNNING') && (
+            {(systemStatus === 'DB_MIGRATION_NEEDED' ||
+              systemStatus === 'DB_MIGRATION_RUNNING') && (
               <>
                 <MaintenanceTitle>
                   <InstanceMessage message={translate('maintenance.is_under_maintenance')} />
@@ -243,7 +274,7 @@ export default class App extends React.PureComponent<Props, State> {
               </>
             )}
 
-            {state === 'NO_MIGRATION' && (
+            {migrationState === MigrationStatus.noMigration && (
               <>
                 <MaintenanceTitle>
                   {translate('maintenance.database_is_up_to_date')}
@@ -255,7 +286,7 @@ export default class App extends React.PureComponent<Props, State> {
               </>
             )}
 
-            {state === 'MIGRATION_REQUIRED' && (
+            {migrationState === MigrationStatus.required && (
               <>
                 <MaintenanceTitle>{translate('maintenance.upgrade_database')}</MaintenanceTitle>
 
@@ -273,7 +304,7 @@ export default class App extends React.PureComponent<Props, State> {
               </>
             )}
 
-            {state === 'NOT_SUPPORTED' && (
+            {migrationState === MigrationStatus.notSupported && (
               <>
                 <MaintenanceTitle className="text-danger">
                   {translate('maintenance.migration_not_supported')}
@@ -283,7 +314,7 @@ export default class App extends React.PureComponent<Props, State> {
               </>
             )}
 
-            {state === 'MIGRATION_RUNNING' && (
+            {migrationState === MigrationStatus.running && (
               <>
                 <MaintenanceTitle>{translate('maintenance.database_migration')}</MaintenanceTitle>
 
@@ -303,12 +334,13 @@ export default class App extends React.PureComponent<Props, State> {
                 )}
 
                 <MaintenanceSpinner>
-                  <Spinner />
+                  <Spinner className="sw-mb-4" />
+                  {progress && <MigrationProgress progress={progress} />}
                 </MaintenanceSpinner>
               </>
             )}
 
-            {state === 'MIGRATION_SUCCEEDED' && (
+            {migrationState === MigrationStatus.succeeded && (
               <>
                 <MaintenanceTitle className="text-success">
                   {translate('maintenance.database_is_up_to_date')}
@@ -320,7 +352,7 @@ export default class App extends React.PureComponent<Props, State> {
               </>
             )}
 
-            {state === 'MIGRATION_FAILED' && (
+            {migrationState === MigrationStatus.failed && (
               <>
                 <MaintenanceTitle className="text-danger">
                   {translate('maintenance.upgrade_failed')}
diff --git a/server/sonar-web/src/main/js/apps/maintenance/components/MigrationProgress.tsx b/server/sonar-web/src/main/js/apps/maintenance/components/MigrationProgress.tsx
new file mode 100644 (file)
index 0000000..f44cf3e
--- /dev/null
@@ -0,0 +1,85 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import styled from '@emotion/styled';
+import * as React from 'react';
+
+import { themeColor } from 'design-system';
+import { FormattedMessage } from 'react-intl';
+import DateTimeFormatter from '../../../components/intl/DateTimeFormatter';
+
+interface Props {
+  progress: {
+    completedSteps: number;
+    totalSteps: number;
+    expectedFinishTimestamp: string;
+  };
+}
+
+export function MigrationProgress({ progress }: Props) {
+  const percentage = `${(progress.completedSteps / progress.totalSteps) * 100}%`;
+
+  return (
+    <>
+      <MigrationProgressContainer>
+        <MigrationBackgroundBar />
+        <MigrationForegroundBar width={percentage} />
+      </MigrationProgressContainer>
+      <div className="sw-mt-2">
+        <FormattedMessage
+          id="maintenance.running.progress"
+          values={{
+            completed: progress.completedSteps,
+            total: progress.totalSteps,
+          }}
+        />
+      </div>
+      <div className="sw-mt-4">
+        <FormattedMessage
+          id="maintenance.running.estimate"
+          values={{
+            date: <DateTimeFormatter date={progress.expectedFinishTimestamp} />,
+          }}
+        />
+      </div>
+    </>
+  );
+}
+
+const MigrationBackgroundBar = styled.div`
+  height: 0.5rem;
+  background-color: ${themeColor('progressBarBackground')};
+  width: 100%;
+  border-radius: 0.25rem;
+`;
+
+const MigrationForegroundBar = styled.div<{ width: string }>`
+  border-top-left-radius: 0.25rem;
+  border-bottom-left-radius: 0.25rem;
+  position: absolute;
+  top: 0;
+  width: ${({ width }) => width};
+  height: 0.5rem;
+  background-color: ${themeColor('progressBarForeground')};
+`;
+
+const MigrationProgressContainer = styled.div`
+  position: relative;
+`;
index b9608bb69935d6d9779f4d8d228554ad48d1cab5..621360a4e3a118888f1bc9683eee0df64fda8cdc 100644 (file)
 import { screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import * as React from 'react';
-import { getMigrationStatus, getSystemStatus, migrateDatabase } from '../../../../api/system';
+import { getMigrationsStatus, getSystemStatus, migrateDatabase } from '../../../../api/system';
 import { mockLocation } from '../../../../helpers/testMocks';
 import { renderApp } from '../../../../helpers/testReactTestingUtils';
+import { byText } from '../../../../helpers/testSelector';
+import { MigrationStatus } from '../../../../types/system';
 import App from '../App';
 
 jest.mock('../../../../api/system', () => ({
-  getMigrationStatus: jest.fn().mockResolvedValue(null),
+  getMigrationsStatus: jest.fn().mockResolvedValue(null),
   getSystemStatus: jest.fn().mockResolvedValue(null),
   migrateDatabase: jest.fn().mockResolvedValue(null),
 }));
@@ -155,13 +157,13 @@ describe('Maintenance', () => {
 describe('Setup', () => {
   it.each([
     [
-      'NO_MIGRATION',
+      MigrationStatus.noMigration,
       'maintenance.database_is_up_to_date',
       undefined,
       { name: 'layout.home', href: '/' },
     ],
     [
-      'MIGRATION_REQUIRED',
+      MigrationStatus.required,
       'maintenance.upgrade_database',
       [
         'maintenance.upgrade_database.1',
@@ -170,28 +172,28 @@ describe('Setup', () => {
       ],
     ],
     [
-      'NOT_SUPPORTED',
+      MigrationStatus.notSupported,
       'maintenance.migration_not_supported',
       ['maintenance.migration_not_supported.text'],
     ],
     [
-      'MIGRATION_RUNNING',
+      MigrationStatus.running,
       'maintenance.database_migration',
       undefined,
       undefined,
       { message: 'MESSAGE', startedAt: '2022-12-01' },
     ],
     [
-      'MIGRATION_SUCCEEDED',
+      MigrationStatus.succeeded,
       'maintenance.database_is_up_to_date',
       undefined,
       { name: 'layout.home', href: '/' },
     ],
-    ['MIGRATION_FAILED', 'maintenance.upgrade_failed', ['maintenance.upgrade_failed.text']],
+    [MigrationStatus.failed, 'maintenance.upgrade_failed', ['maintenance.upgrade_failed.text']],
   ])(
     'should handle "%p" state correctly',
-    async (state, heading, bodyText: string[] = [], linkInfo = undefined, payload = undefined) => {
-      (getMigrationStatus as jest.Mock).mockResolvedValueOnce({ state, ...payload });
+    async (status, heading, bodyText: string[] = [], linkInfo = undefined, payload = undefined) => {
+      jest.mocked(getMigrationsStatus).mockResolvedValueOnce({ status, ...payload });
       renderSetupApp();
 
       const title = await screen.findByRole('heading', { name: heading });
@@ -227,10 +229,16 @@ describe('Setup', () => {
       startedAt: '2022-12-01',
       state: 'MIGRATION_RUNNING',
     });
-    (getMigrationStatus as jest.Mock)
-      .mockResolvedValueOnce({ state: 'MIGRATION_REQUIRED' })
-      .mockResolvedValueOnce({ state: 'MIGRATION_RUNNING' })
-      .mockResolvedValueOnce({ state: 'MIGRATION_SUCCEEDED' });
+    jest
+      .mocked(getMigrationsStatus)
+      .mockResolvedValueOnce({ status: MigrationStatus.required })
+      .mockResolvedValueOnce({
+        status: MigrationStatus.running,
+        completedSteps: 28,
+        totalSteps: 42,
+        expectedFinishTimestamp: '2027-11-10T13:42:20',
+      })
+      .mockResolvedValueOnce({ status: MigrationStatus.succeeded });
 
     renderSetupApp();
     const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
@@ -253,6 +261,8 @@ describe('Setup', () => {
     title = await screen.findByRole('heading', { name: 'maintenance.database_migration' });
     expect(title).toBeInTheDocument();
 
+    expect(byText(/maintenance.running.progress/).get()).toBeInTheDocument();
+
     // Trigger refresh; migration done.
     jest.runOnlyPendingTimers();
 
index 0dc366dbf6f2923f277612663813cd74b1029970..a7f3912f2a966915806a46925e7fde23a758c3d0 100644 (file)
@@ -35,3 +35,21 @@ export enum InstanceType {
   SonarQube = 'SonarQube',
   SonarCloud = 'SonarCloud',
 }
+
+export enum MigrationStatus {
+  noMigration = 'NO_MIGRATION',
+  notSupported = 'NOT_SUPPORTED',
+  required = 'MIGRATION_REQUIRED',
+  running = 'MIGRATION_RUNNING',
+  succeeded = 'MIGRATION_SUCCEEDED',
+  failed = 'MIGRATION_FAILED',
+}
+
+export interface MigrationsStatusResponse {
+  status: MigrationStatus;
+  completedSteps?: number;
+  totalSteps?: number;
+  startedAt?: string;
+  message?: string;
+  expectedFinishTimestamp?: string;
+}
index e3f02e76d0914cf45939aa7129f99e23625e717d..6f05b21e39b906a429aeef7497225d11d1442bfd 100644 (file)
@@ -5240,6 +5240,8 @@ maintenance.is_up={instance} is up
 maintenance.all_systems_opetational=All systems operational.
 maintenance.is_offline={instance} is offline
 maintenance.sonarqube_is_offline.text=The connection to SonarQube is lost. Please contact your system administrator.
+maintenance.running.progress={completed} migrations completed out of {total}
+maintenance.running.estimate=Estimated completion time: {date}
 
 #------------------------------------------------------------------------------
 #