* 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);
}
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');
}
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';
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 } };
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;
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();
}
};
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 = () => {
migrateDatabase().then(
({ message, startedAt, state }) => {
if (this.mounted) {
- this.setState({ message, startedAt, state });
+ this.setState({ message, startedAt, migrationState: state });
}
},
() => {},
};
render() {
- const { state, status } = this.state;
+ const { migrationState, systemStatus, progress } = this.state;
return (
<>
<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')} />
</>
)}
- {status === 'UP' && (
+ {systemStatus === 'UP' && (
<>
<MaintenanceTitle>
<InstanceMessage message={translate('maintenance.is_up')} />
</>
)}
- {status === 'STARTING' && (
+ {systemStatus === 'STARTING' && (
<>
<MaintenanceTitle>
<InstanceMessage message={translate('maintenance.is_starting')} />
</>
)}
- {status === 'DOWN' && (
+ {systemStatus === 'DOWN' && (
<>
<MaintenanceTitle className="text-danger">
<InstanceMessage message={translate('maintenance.is_down')} />
</>
)}
- {(status === 'DB_MIGRATION_NEEDED' || status === 'DB_MIGRATION_RUNNING') && (
+ {(systemStatus === 'DB_MIGRATION_NEEDED' ||
+ systemStatus === 'DB_MIGRATION_RUNNING') && (
<>
<MaintenanceTitle>
<InstanceMessage message={translate('maintenance.is_under_maintenance')} />
</>
)}
- {state === 'NO_MIGRATION' && (
+ {migrationState === MigrationStatus.noMigration && (
<>
<MaintenanceTitle>
{translate('maintenance.database_is_up_to_date')}
</>
)}
- {state === 'MIGRATION_REQUIRED' && (
+ {migrationState === MigrationStatus.required && (
<>
<MaintenanceTitle>{translate('maintenance.upgrade_database')}</MaintenanceTitle>
</>
)}
- {state === 'NOT_SUPPORTED' && (
+ {migrationState === MigrationStatus.notSupported && (
<>
<MaintenanceTitle className="text-danger">
{translate('maintenance.migration_not_supported')}
</>
)}
- {state === 'MIGRATION_RUNNING' && (
+ {migrationState === MigrationStatus.running && (
<>
<MaintenanceTitle>{translate('maintenance.database_migration')}</MaintenanceTitle>
)}
<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')}
</>
)}
- {state === 'MIGRATION_FAILED' && (
+ {migrationState === MigrationStatus.failed && (
<>
<MaintenanceTitle className="text-danger">
{translate('maintenance.upgrade_failed')}
--- /dev/null
+/*
+ * 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;
+`;
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),
}));
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',
],
],
[
- '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 });
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 });
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();
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;
+}
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}
#------------------------------------------------------------------------------
#