The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/public/app/features/migrate-to-cloud/onprem/Page.tsx

496 lines
17 KiB

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 { maybeAPIError } from '../api/errors';
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<SnapshotDto['status']> = [
'INITIALIZING',
'CREATING',
'UPLOADING',
'PENDING_PROCESSING',
'PROCESSING',
];
const SNAPSHOT_REBUILD_STATUSES: Array<SnapshotDto['status']> = ['PENDING_UPLOAD', 'FINISHED', 'ERROR', 'UNKNOWN'];
const SNAPSHOT_BUILDING_STATUSES: Array<SnapshotDto['status']> = ['INITIALIZING', 'CREATING'];
const SNAPSHOT_UPLOADING_STATUSES: Array<SnapshotDto['status']> = ['UPLOADING', 'PENDING_PROCESSING', 'PROCESSING'];
const SNAPSHOT_RESOURCES_HAVE_ERROR_STATUSES: Array<SnapshotDto['status']> = [
'PROCESSING',
'PENDING_PROCESSING',
'FINISHED',
];
const PAGE_SIZE = 50;
function useGetLatestSnapshot(sessionUid?: string, page = 1, sortParams?: SortParams, showErrors = false) {
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,
resultSortColumn: sortParams?.column ? sortParams.column : undefined,
resultSortOrder: sortParams?.order,
errorsOnly: showErrors,
}
: 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,
};
}
interface SortParams {
column: string;
order: string | undefined;
}
export const Page = () => {
const [disconnectModalOpen, setDisconnectModalOpen] = useState(false);
const [page, setPage] = useState(1);
const [sortParams, setSortParams] = useState<SortParams>({
column: '',
order: undefined,
});
const [highlightErrors, setHighlightErrors] = useState(false);
const [performCreateSnapshot, createSnapshotResult] = useCreateSnapshotMutation();
const [performUploadSnapshot, uploadSnapshotResult] = useUploadSnapshotMutation();
const [performCancelSnapshot, cancelSnapshotResult] = useCancelSnapshotMutation();
const [performDisconnect, disconnectResult] = useDeleteSessionMutation();
const { currentData: localPlugins = [] } = useGetLocalPluginListQuery();
const session = useGetLatestSession();
const snapshot = useGetLatestSnapshot(session.data?.uid, page, sortParams, highlightErrors);
const numPages = Math.ceil(
(highlightErrors ? snapshot?.data?.stats?.statuses?.['ERROR'] || 0 : snapshot?.data?.stats?.total || 0) / PAGE_SIZE
);
useEffect(() => {
if (numPages > 0 && page > numPages) {
setPage(numPages);
}
}, [numPages, page]);
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 showOnlyErrorsSwitch = SNAPSHOT_RESOURCES_HAVE_ERROR_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,
createSnapshotRequestDto: {
// TODO: For the moment, pass all resource types. Once we have a frontend for selecting resource types,
// we should pass the selected resource types instead.
resourceTypes: [
'DASHBOARD',
'DATASOURCE',
'FOLDER',
'LIBRARY_ELEMENT',
'ALERT_RULE',
'ALERT_RULE_GROUP',
'CONTACT_POINT',
'NOTIFICATION_POLICY',
'NOTIFICATION_TEMPLATE',
'MUTE_TIMING',
'PLUGIN',
],
},
});
}
}, [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 (
<div>
<Trans i18nKey="migrate-to-cloud.summary.page-loading">Loading...</Trans>
</div>
);
} else if (!session.data) {
return <EmptyState />;
}
return (
<>
<Stack direction="column" gap={2}>
{session.data && (
<MigrationSummary
session={session.data}
snapshot={snapshot.data}
isBusy={isBusy}
disconnectIsLoading={disconnectResult.isLoading}
onDisconnect={handleDisconnect}
showBuildSnapshot={showBuildSnapshot}
buildSnapshotIsLoading={createSnapshotResult.isLoading}
onBuildSnapshot={handleCreateSnapshot}
showUploadSnapshot={showUploadSnapshot}
uploadSnapshotIsLoading={uploadSnapshotResult.isLoading || SNAPSHOT_UPLOADING_STATUSES.includes(status)}
onUploadSnapshot={handleUploadSnapshot}
showRebuildSnapshot={showRebuildSnapshot}
onHighlightErrors={() => setHighlightErrors(!highlightErrors)}
isHighlightErrors={highlightErrors}
showOnlyErrorsSwitch={showOnlyErrorsSwitch}
/>
)}
{error && (
<AlertWithTraceID severity={error.severity} title={error.title} error={error.error}>
<Text element="p">{error.body}</Text>
</AlertWithTraceID>
)}
{(showBuildSnapshot || showBuildingSnapshot) && (
<Box display="flex" justifyContent="center" paddingY={10}>
{showBuildSnapshot && (
<BuildSnapshotCTA
disabled={isBusy}
isLoading={createSnapshotResult.isLoading}
onClick={handleCreateSnapshot}
/>
)}
{showBuildingSnapshot && (
<CreatingSnapshotCTA
disabled={isBusy}
isLoading={cancelSnapshotResult.isLoading}
onClick={handleCancelSnapshot}
/>
)}
</Box>
)}
{snapshot.data?.results && snapshot.data.results.length > 0 && (
<Stack gap={4} direction="column">
<ResourcesTable
resources={snapshot.data.results}
localPlugins={localPlugins}
onChangePage={setPage}
numberOfPages={numPages}
page={page}
onChangeSort={(a) => {
const order = a.sortBy[0]?.desc === undefined ? undefined : a.sortBy[0]?.desc ? 'desc' : 'asc';
if (sortParams.column !== a.sortBy[0]?.id || order !== sortParams.order) {
setSortParams({
column: a.sortBy[0]?.id,
order: order,
});
}
}}
/>
<SupportedTypesDisclosure />
</Stack>
)}
</Stack>
<DisconnectModal
isOpen={disconnectModalOpen}
isLoading={disconnectResult.isLoading}
isError={disconnectResult.isError}
onDisconnectConfirm={handleDisconnect}
onDismiss={() => 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 handleCreateSnapshotError(createSnapshotError, seeLogs);
}
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;
}
function handleCreateSnapshotError(createSnapshotError: unknown, seeLogs: string): ErrorDescription | undefined {
const apiError = maybeAPIError(createSnapshotError);
let severity: AlertVariant = 'warning';
let body = null;
switch (apiError?.messageId) {
case 'cloudmigrations.emptyResourceTypes':
severity = 'error';
body = t(
'migrate-to-cloud.onprem.create-snapshot-error-empty-resource-types',
'You need to provide at least one resource type for snapshot creation'
);
break;
case 'cloudmigrations.unknownResourceType':
severity = 'error';
body = t(
'migrate-to-cloud.onprem.create-snapshot-error-unknown-resource-type',
'Unknown resource type. See the Grafana server logs for more details'
);
break;
case 'cloudmigrations.duplicateResourceType':
severity = 'error';
body = t(
'migrate-to-cloud.onprem.create-snapshot-error-duplicate-resource-type',
'Duplicate resource type. See the Grafana server logs for more details'
);
break;
case 'cloudmigrations.missingDependency':
severity = 'error';
body = t(
'migrate-to-cloud.onprem.create-snapshot-error-missing-dependency',
'Missing dependency. See the Grafana server logs for more details'
);
break;
}
return {
severity,
title: t('migrate-to-cloud.onprem.create-snapshot-error-title', 'Error creating snapshot'),
body: body || seeLogs,
error: createSnapshotError,
};
}