From: Jeremy Davis Date: Fri, 3 May 2024 07:53:07 +0000 (+0200) Subject: SONAR-22141 Show progress bar and estimate on migration page X-Git-Tag: 10.6.0.92116~127 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=11dbe7d367ed9ead2224e451b050ab7e468ea952;p=sonarqube.git SONAR-22141 Show progress bar and estimate on migration page --- diff --git a/server/sonar-web/src/main/js/api/system.ts b/server/sonar-web/src/main/js/api/system.ts index 9bf9ca49e6f..ba72977355b 100644 --- a/server/sonar-web/src/main/js/api/system.ts +++ b/server/sonar-web/src/main/js/api/system.ts @@ -17,12 +17,15 @@ * 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 { 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(MIGRATIONS_STATUS_ENDPOINT); } export function migrateDatabase(): Promise<{ message?: string; startedAt?: string; - state: string; + state: MigrationStatus; }> { return postJSON('/api/system/migrate_db'); } diff --git a/server/sonar-web/src/main/js/apps/maintenance/components/App.tsx b/server/sonar-web/src/main/js/apps/maintenance/components/App.tsx index be38e085a10..90a89d048fd 100644 --- a/server/sonar-web/src/main/js/apps/maintenance/components/App.tsx +++ b/server/sonar-web/src/main/js/apps/maintenance/components/App.tsx @@ -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 { 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 { }; 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 { 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 { }; render() { - const { state, status } = this.state; + const { migrationState, systemStatus, progress } = this.state; return ( <> @@ -145,7 +175,7 @@ export default class App extends React.PureComponent { - {status === 'OFFLINE' && ( + {systemStatus === 'OFFLINE' && ( <> @@ -163,7 +193,7 @@ export default class App extends React.PureComponent { )} - {status === 'UP' && ( + {systemStatus === 'UP' && ( <> @@ -179,7 +209,7 @@ export default class App extends React.PureComponent { )} - {status === 'STARTING' && ( + {systemStatus === 'STARTING' && ( <> @@ -191,7 +221,7 @@ export default class App extends React.PureComponent { )} - {status === 'DOWN' && ( + {systemStatus === 'DOWN' && ( <> @@ -207,7 +237,8 @@ export default class App extends React.PureComponent { )} - {(status === 'DB_MIGRATION_NEEDED' || status === 'DB_MIGRATION_RUNNING') && ( + {(systemStatus === 'DB_MIGRATION_NEEDED' || + systemStatus === 'DB_MIGRATION_RUNNING') && ( <> @@ -243,7 +274,7 @@ export default class App extends React.PureComponent { )} - {state === 'NO_MIGRATION' && ( + {migrationState === MigrationStatus.noMigration && ( <> {translate('maintenance.database_is_up_to_date')} @@ -255,7 +286,7 @@ export default class App extends React.PureComponent { )} - {state === 'MIGRATION_REQUIRED' && ( + {migrationState === MigrationStatus.required && ( <> {translate('maintenance.upgrade_database')} @@ -273,7 +304,7 @@ export default class App extends React.PureComponent { )} - {state === 'NOT_SUPPORTED' && ( + {migrationState === MigrationStatus.notSupported && ( <> {translate('maintenance.migration_not_supported')} @@ -283,7 +314,7 @@ export default class App extends React.PureComponent { )} - {state === 'MIGRATION_RUNNING' && ( + {migrationState === MigrationStatus.running && ( <> {translate('maintenance.database_migration')} @@ -303,12 +334,13 @@ export default class App extends React.PureComponent { )} - + + {progress && } )} - {state === 'MIGRATION_SUCCEEDED' && ( + {migrationState === MigrationStatus.succeeded && ( <> {translate('maintenance.database_is_up_to_date')} @@ -320,7 +352,7 @@ export default class App extends React.PureComponent { )} - {state === 'MIGRATION_FAILED' && ( + {migrationState === MigrationStatus.failed && ( <> {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 index 00000000000..f44cf3e9026 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/maintenance/components/MigrationProgress.tsx @@ -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 ( + <> + + + + +
+ +
+
+ , + }} + /> +
+ + ); +} + +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; +`; diff --git a/server/sonar-web/src/main/js/apps/maintenance/components/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/maintenance/components/__tests__/App-test.tsx index b9608bb6993..621360a4e3a 100644 --- a/server/sonar-web/src/main/js/apps/maintenance/components/__tests__/App-test.tsx +++ b/server/sonar-web/src/main/js/apps/maintenance/components/__tests__/App-test.tsx @@ -20,13 +20,15 @@ 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(); diff --git a/server/sonar-web/src/main/js/types/system.ts b/server/sonar-web/src/main/js/types/system.ts index 0dc366dbf6f..a7f3912f2a9 100644 --- a/server/sonar-web/src/main/js/types/system.ts +++ b/server/sonar-web/src/main/js/types/system.ts @@ -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; +} 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 e3f02e76d09..6f05b21e39b 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -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} #------------------------------------------------------------------------------ #