import { skipToken } from '@reduxjs/toolkit/query/react'; import { useCallback, useEffect, useState } from 'react'; import { config } from '@grafana/runtime'; import { AlertVariant, Box, Stack, Text } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; import { GetSnapshotResponseDto, SnapshotDto, useCancelSnapshotMutation, useCreateSnapshotMutation, useDeleteSessionMutation, useGetSessionListQuery, useGetShapshotListQuery, useGetSnapshotQuery, useUploadSnapshotMutation, useGetLocalPluginListQuery, } from '../api'; import { AlertWithTraceID } from '../shared/AlertWithTraceID'; import { DisconnectModal } from './DisconnectModal'; import { EmptyState } from './EmptyState/EmptyState'; import { MigrationSummary } from './MigrationSummary'; import { ResourcesTable } from './ResourcesTable'; import { BuildSnapshotCTA, CreatingSnapshotCTA } from './SnapshotCTAs'; import { SupportedTypesDisclosure } from './SupportedTypesDisclosure'; import { useNotifySuccessful } from './useNotifyOnSuccess'; /** * Here's how migrations work: * * A single on-prem instance can be configured to be migrated to multiple cloud instances. We call these 'sessions'. * - GetSessionList returns this the list of migration targets for the on prem instance * - If GetMigrationList returns an empty list, then an empty state to prompt for token should be shown * - The UI (at the moment) only shows the most recently created migration target (the last one returned from the API) * and doesn't allow for others to be created * * A single on-prem migration 'target' (CloudMigrationSession) can have multiple snapshots. * A snapshot represents a copy of all migratable resources at a fixed point in time. * A snapshots are created asynchronously in the background, so GetSnapshot must be polled to get the current status. * * After a snapshot has been created, it will be PENDING_UPLOAD. UploadSnapshot is then called which asynchronously * uploads and migrates the snapshot to the cloud instance. */ function useGetLatestSession() { const result = useGetSessionListQuery(); const latestMigration = result.data?.sessions?.at(-1); return { ...result, data: latestMigration, }; } const SHOULD_POLL_STATUSES: Array = [ 'INITIALIZING', 'CREATING', 'UPLOADING', 'PENDING_PROCESSING', 'PROCESSING', ]; const SNAPSHOT_REBUILD_STATUSES: Array = ['PENDING_UPLOAD', 'FINISHED', 'ERROR', 'UNKNOWN']; const SNAPSHOT_BUILDING_STATUSES: Array = ['INITIALIZING', 'CREATING']; const SNAPSHOT_UPLOADING_STATUSES: Array = ['UPLOADING', 'PENDING_PROCESSING', 'PROCESSING']; const PAGE_SIZE = 50; function useGetLatestSnapshot(sessionUid?: string, page = 1) { const [shouldPoll, setShouldPoll] = useState(false); const listResult = useGetShapshotListQuery( sessionUid ? { uid: sessionUid, page: 1, limit: 1, sort: 'latest' } : skipToken ); const lastItem = listResult.currentData?.snapshots?.at(0); const getSnapshotQueryArgs = sessionUid && lastItem?.uid ? { uid: sessionUid, snapshotUid: lastItem.uid, resultLimit: PAGE_SIZE, resultPage: page } : skipToken; const snapshotResult = useGetSnapshotQuery(getSnapshotQueryArgs, { pollingInterval: shouldPoll ? config.cloudMigrationPollIntervalMs : 0, skipPollingIfUnfocused: true, }); const isError = listResult.isError || snapshotResult.isError; useEffect(() => { const shouldPoll = !isError && SHOULD_POLL_STATUSES.includes(snapshotResult.data?.status); setShouldPoll(shouldPoll); }, [snapshotResult?.data?.status, isError]); return { ...snapshotResult, // RTK Query will retain old data if a new request has been skipped. // This meant that if you loaded a snapshot, disconnected, and then reconnected, we would // show the old snapshot. // This ensures that if the query has been skipped (because GetSessionList returned nothing) // we don't return stale data data: getSnapshotQueryArgs === skipToken ? undefined : snapshotResult.data, error: listResult.error || snapshotResult.error, // isSuccess and isUninitialised should always be from snapshotResult // as only the 'final' values from those are important isError, isLoading: listResult.isLoading || snapshotResult.isLoading, isFetching: listResult.isFetching || snapshotResult.isFetching, }; } export const Page = () => { const [disconnectModalOpen, setDisconnectModalOpen] = useState(false); const session = useGetLatestSession(); const [page, setPage] = useState(1); const snapshot = useGetLatestSnapshot(session.data?.uid, page); const [performCreateSnapshot, createSnapshotResult] = useCreateSnapshotMutation(); const [performUploadSnapshot, uploadSnapshotResult] = useUploadSnapshotMutation(); const [performCancelSnapshot, cancelSnapshotResult] = useCancelSnapshotMutation(); const [performDisconnect, disconnectResult] = useDeleteSessionMutation(); const { currentData: localPlugins = [] } = useGetLocalPluginListQuery(); useNotifySuccessful(snapshot.data); const sessionUid = session.data?.uid; const snapshotUid = snapshot.data?.uid; const isInitialLoading = session.isLoading; const status = snapshot.data?.status; // isBusy is not a loading state, but indicates that the system is doing *something* // and all buttons should be disabled const isBusy = createSnapshotResult.isLoading || uploadSnapshotResult.isLoading || cancelSnapshotResult.isLoading || session.isLoading || snapshot.isLoading || disconnectResult.isLoading; const showBuildSnapshot = !snapshot.isError && !snapshot.isLoading && !snapshot.data; const showBuildingSnapshot = SNAPSHOT_BUILDING_STATUSES.includes(status); const showUploadSnapshot = !snapshot.isError && (status === 'PENDING_UPLOAD' || SNAPSHOT_UPLOADING_STATUSES.includes(status)); const showRebuildSnapshot = SNAPSHOT_REBUILD_STATUSES.includes(status); const error = getError({ snapshot: snapshot.data, getSnapshotError: snapshot.error, getSessionError: session.error, createSnapshotError: createSnapshotResult.error, uploadSnapshotError: uploadSnapshotResult.error, cancelSnapshotError: cancelSnapshotResult.error, disconnectSnapshotError: disconnectResult.error, }); const handleDisconnect = useCallback(async () => { if (sessionUid) { performDisconnect({ uid: sessionUid }); } }, [performDisconnect, sessionUid]); const handleCreateSnapshot = useCallback(() => { if (sessionUid) { performCreateSnapshot({ uid: sessionUid }); } }, [performCreateSnapshot, sessionUid]); const handleUploadSnapshot = useCallback(() => { if (sessionUid && snapshotUid) { performUploadSnapshot({ uid: sessionUid, snapshotUid: snapshotUid }); } }, [performUploadSnapshot, sessionUid, snapshotUid]); const handleCancelSnapshot = useCallback(() => { if (sessionUid && snapshotUid) { performCancelSnapshot({ uid: sessionUid, snapshotUid: snapshotUid }); } }, [performCancelSnapshot, sessionUid, snapshotUid]); if (isInitialLoading) { // TODO: better loading state return (
Loading...
); } else if (!session.data) { return ; } return ( <> {session.data && ( )} {error && ( {error.body} )} {(showBuildSnapshot || showBuildingSnapshot) && ( {showBuildSnapshot && ( )} {showBuildingSnapshot && ( )} )} {snapshot.data?.results && snapshot.data.results.length > 0 && ( )} setDisconnectModalOpen(false)} /> ); }; interface GetErrorProps { snapshot: GetSnapshotResponseDto | undefined; getSessionError: unknown; // From getLatestSessionQuery getSnapshotError: unknown; // From getLatestSnapshotQuery createSnapshotError: unknown; // From createSnapshotMutation uploadSnapshotError: unknown; // From uploadSnapshotMutation cancelSnapshotError: unknown; // From cancelSnapshotMutation disconnectSnapshotError: unknown; // From disconnectMutation } interface ErrorDescription { title: string; body: string; severity: AlertVariant; error?: unknown; } function getError(props: GetErrorProps): ErrorDescription | undefined { const { snapshot, getSnapshotError, getSessionError, createSnapshotError, uploadSnapshotError, cancelSnapshotError, disconnectSnapshotError, } = props; const seeLogs = t('migrate-to-cloud.onprem.error-see-server-logs', 'See the Grafana server logs for more details'); if (getSessionError) { return { severity: 'error', title: t('migrate-to-cloud.onprem.get-session-error-title', 'Error loading migration configuration'), body: seeLogs, error: getSessionError, }; } if (getSnapshotError) { return { severity: 'error', title: t('migrate-to-cloud.onprem.get-snapshot-error-title', 'Error loading snapshot'), body: seeLogs, error: getSnapshotError, }; } if (disconnectSnapshotError) { return { severity: 'warning', title: t('migrate-to-cloud.onprem.disconnect-error-title', 'Error disconnecting'), body: seeLogs, error: disconnectSnapshotError, }; } if (createSnapshotError) { return { severity: 'warning', title: t('migrate-to-cloud.onprem.create-snapshot-error-title', 'Error creating snapshot'), body: seeLogs, error: createSnapshotError, }; } if (uploadSnapshotError) { return { severity: 'warning', title: t('migrate-to-cloud.onprem.upload-snapshot-error-title', 'Error uploading snapshot'), body: seeLogs, error: uploadSnapshotError, }; } if (cancelSnapshotError) { return { severity: 'warning', title: t('migrate-to-cloud.onprem.cancel-snapshot-error-title', 'Error cancelling creating snapshot'), body: seeLogs, error: cancelSnapshotError, }; } if (snapshot?.status === 'ERROR') { return { severity: 'warning', title: t('migrate-to-cloud.onprem.snapshot-error-status-title', 'Error migrating resources'), body: t( 'migrate-to-cloud.onprem.snapshot-error-status-body', 'There was an error creating the snapshot or starting the migration process. See the Grafana server logs for more details' ), }; } const errorCount = snapshot?.stats?.statuses?.['ERROR'] ?? 0; const warningCount = snapshot?.stats?.statuses?.['WARNING'] ?? 0; if (snapshot?.status === 'FINISHED' && errorCount + warningCount > 0) { let msgBody = ''; // If there are any errors, that's the most pressing info. If there are no errors but warnings, show the warning text instead. if (errorCount > 0) { msgBody = t( 'migrate-to-cloud.onprem.migration-finished-with-errors-body', 'The migration has completed, but some items could not be migrated to the cloud stack. Check the failed resources for more details' ); } else if (warningCount > 0) { msgBody = t( 'migrate-to-cloud.onprem.migration-finished-with-warnings-body', 'The migration has completed with some warnings. Check individual resources for more details' ); } return { severity: 'warning', title: t('migrate-to-cloud.onprem.migration-finished-with-caveat-title', 'Resource migration complete'), body: msgBody, }; } return undefined; }