CloudMigrations: Enable high-level resource type selection (#103011)

* CloudMigrations: Enable snapshot resource type configuration

* ConfigureSnapshot: Update resource type order

* ConfigureSnapshot: Add compile-time check for all resource types

* Fix trickier merge conflicts

* E2E: Fix intercept calls to get snapshot results to match new query params for sorting

* ConfigureSnapshot: Break text into newline

* ConfigureSnapshot: Add spacing on resources list

* Chore: Run betterer

* ConfigureSnapshot: Make secondary text bold

* ConfigureSnapshot: Update copy which resources to migrate

* ConfigureSnapshot: Add tooltip near build snapshot button with ETA
This commit is contained in:
Matheus Macabu 2025-04-10 14:14:13 +02:00 committed by GitHub
parent f201fbc8d2
commit b58b6e828e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1130 additions and 152 deletions

View File

@ -2570,10 +2570,18 @@ exports[`better eslint`] = {
"public/app/features/migrate-to-cloud/api/index.ts:5381": [
[0, 0, 0, "Do not use export all (\`export * from ...\`)", "0"]
],
"public/app/features/migrate-to-cloud/onprem/ConfigureSnapshot.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"public/app/features/migrate-to-cloud/onprem/NameCell.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/migrate-to-cloud/onprem/resourceDependency.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"public/app/features/migrate-to-cloud/onprem/useNotifyOnSuccess.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],

View File

@ -1,6 +1,6 @@
import { e2e } from '../utils';
describe.skip('Migrate to Cloud (On-prem)', () => {
describe('Migrate to Cloud (On-prem)', () => {
// Here we are mostly testing the UI flow and can do interesting things with the backend responses to see how the UI behaves.
describe('with mocked calls to the API backend', () => {
afterEach(() => {
@ -96,6 +96,55 @@ describe.skip('Migrate to Cloud (On-prem)', () => {
// Wait for the token to be created and the migration session list to be fetched to kickstart the UI state machine.
cy.wait(['@createMigrationToken', '@getMigrationSessionList', '@getSnapshotListInitial']);
// Check the 'Include all' resources checkbox.
cy.get('[data-testid="migrate-to-cloud-configure-snapshot-checkbox-resource-include-all"]')
.check({ force: true })
.should('be.checked');
// And validate that all resources are indeed checked.
for (const resourceType of [
'alert_rule',
'alert_rule_group',
'contact_point',
'dashboard',
'datasource',
'folder',
'library_element',
'mute_timing',
'notification_policy',
'notification_template',
'plugin',
]) {
cy.get(
`[data-testid="migrate-to-cloud-configure-snapshot-checkbox-resource-${resourceType.toLowerCase()}"]`
).should('be.checked');
}
// Remove one of the resources that has dependencies.
// Mute Timings are dependencies of Alert Rules, which are dependencies of Alert Rule Groups.
cy.get(`[data-testid="migrate-to-cloud-configure-snapshot-checkbox-resource-mute_timing"]`)
.uncheck({ force: true })
.should('not.be.checked');
// Validate that those resources are now unchecked.
for (const resourceType of ['alert_rule', 'alert_rule_group', 'include-all']) {
cy.get(
`[data-testid="migrate-to-cloud-configure-snapshot-checkbox-resource-${resourceType.toLowerCase()}"]`
).should('not.be.checked');
}
// Check everything again because we can.
cy.get('[data-testid="migrate-to-cloud-configure-snapshot-checkbox-resource-include-all"]')
.check({ force: true })
.should('be.checked');
// Validate that those resources are now checked again.
for (const resourceType of ['alert_rule', 'alert_rule_group', 'mute_timing']) {
cy.get(
`[data-testid="migrate-to-cloud-configure-snapshot-checkbox-resource-${resourceType.toLowerCase()}"]`
).should('be.checked');
}
cy.intercept('POST', `/api/cloudmigration/migration/${SESSION_UID}/snapshot`, {
statusCode: 200,
body: {
@ -121,7 +170,7 @@ describe.skip('Migrate to Cloud (On-prem)', () => {
let getSnapshotCalled = false;
cy.intercept(
'GET',
`/api/cloudmigration/migration/${SESSION_UID}/snapshot/${SNAPSHOT_UID1}?resultPage=1&resultLimit=50`,
`/api/cloudmigration/migration/${SESSION_UID}/snapshot/${SNAPSHOT_UID1}?resultPage=1&resultLimit=50*`,
(req) => {
if (!getSnapshotCalled) {
getSnapshotCalled = true;
@ -172,6 +221,13 @@ describe.skip('Migrate to Cloud (On-prem)', () => {
statusCode: 200,
}).as('uploadSnapshot');
// Upload the snapshot.
cy.get('[data-testid="migrate-to-cloud-summary-upload-snapshot-button"]')
.should('be.visible')
.wait(2000)
.focus()
.trigger('click', { force: true, waitForAnimations: true });
cy.intercept('GET', `/api/cloudmigration/migration/${SESSION_UID}/snapshots?page=1&limit=1*`, {
statusCode: 200,
body: {
@ -187,14 +243,11 @@ describe.skip('Migrate to Cloud (On-prem)', () => {
},
}).as('getSnapshotListUploading');
// Upload the snapshot.
cy.get('[data-testid="migrate-to-cloud-summary-upload-snapshot-button"]').should('be.visible').click();
// Simulate the snapshot being uploaded, the frontend will keep polling until the snapshot is either finished or errored.
let getSnapshotUploadingCalls = 0;
cy.intercept(
'GET',
`/api/cloudmigration/migration/${SESSION_UID}/snapshot/${SNAPSHOT_UID1}?resultPage=1&resultLimit=50`,
`/api/cloudmigration/migration/${SESSION_UID}/snapshot/${SNAPSHOT_UID1}?resultPage=1&resultLimit=50*`,
(req) => {
req.reply((res) => {
if (getSnapshotUploadingCalls <= 1) {
@ -243,14 +296,16 @@ describe.skip('Migrate to Cloud (On-prem)', () => {
// Wait for the request to kickstart the upload and then wait until it is finished.
cy.wait(['@uploadSnapshot', '@getSnapshotListUploading', '@getSnapshotUploading']);
// The upload button should now be hidden away.
cy.get('[data-testid="migrate-to-cloud-summary-upload-snapshot-button"]').should('be.disabled');
// And the rebuild button should be visible.
cy.get('[data-testid="migrate-to-cloud-summary-rebuild-snapshot-button"]').should('be.visible');
// At least some of the items are marked with "Uploaded to cloud" status.
cy.contains('Uploaded to cloud').should('be.visible');
// We can now reconfigure the snapshot.
cy.get('[data-testid="migrate-to-cloud-summary-reconfigure-snapshot-button"]').should('be.visible').click();
// Check the 'Include all' resources checkbox.
cy.get('[data-testid="migrate-to-cloud-configure-snapshot-checkbox-resource-include-all"]')
.check({ force: true })
.should('be.checked');
});
});
@ -289,7 +344,7 @@ describe.skip('Migrate to Cloud (On-prem)', () => {
cy.get('[data-testid="migrate-to-cloud-configure-snapshot-build-snapshot-button"]').should('be.visible').click();
// And the rebuild button should be visible.
cy.get('[data-testid="migrate-to-cloud-summary-rebuild-snapshot-button"]').should('be.visible');
cy.get('[data-testid="migrate-to-cloud-summary-reconfigure-snapshot-button"]').should('be.visible');
// We don't upload the snapshot yet because we need to create a mock server to validate the uploaded items,
// similarly to what the SMTP (tester) server does.

View File

@ -0,0 +1,175 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ResourceDependencyDto } from '../api';
import { ConfigureSnapshot } from './ConfigureSnapshot';
import { ResourceTypeId } from './resourceDependency';
// Mock the functions imported by the component
jest.mock('./resourceDependency', () => {
const originalModule = jest.requireActual('./resourceDependency');
return {
...originalModule,
buildDependencyMaps: jest.fn(originalModule.buildDependencyMaps),
handleSelection: jest.fn(originalModule.handleSelection),
handleDeselection: jest.fn(originalModule.handleDeselection),
};
});
jest.mock('./resourceInfo', () => {
return {
iconNameForResource: jest.fn(() => 'dashboard'),
pluralizeResourceName: jest.fn((type) => {
switch (type) {
case 'DASHBOARD':
return 'Dashboards';
case 'FOLDER':
return 'Folders';
case 'DATASOURCE':
return 'Data Sources';
default:
return type;
}
}),
};
});
describe(ConfigureSnapshot.name, () => {
const mockResourceDependencies: ResourceDependencyDto[] = [
{
resourceType: 'DASHBOARD',
dependencies: ['FOLDER', 'DATASOURCE'],
},
{
resourceType: 'FOLDER',
dependencies: [],
},
{
resourceType: 'DATASOURCE',
dependencies: [],
},
];
const setup = (propOverrides?: Partial<React.ComponentProps<typeof ConfigureSnapshot>>) => {
const props = {
disabled: false,
isLoading: false,
onClick: jest.fn() as jest.Mock<void, [ResourceTypeId[]]>,
resourceDependencies: mockResourceDependencies,
...propOverrides,
};
return {
...render(<ConfigureSnapshot {...props} />),
props,
};
};
it('should render all checkboxes and build snapshot button', () => {
setup();
expect(screen.getByTestId('migrate-to-cloud-configure-snapshot-checkbox-resource-include-all')).toBeChecked();
expect(screen.getByTestId('migrate-to-cloud-configure-snapshot-checkbox-resource-dashboard')).toBeChecked();
expect(screen.getByTestId('migrate-to-cloud-configure-snapshot-checkbox-resource-folder')).toBeChecked();
expect(screen.getByTestId('migrate-to-cloud-configure-snapshot-checkbox-resource-datasource')).toBeChecked();
expect(screen.getByTestId('migrate-to-cloud-configure-snapshot-build-snapshot-button')).toBeInTheDocument();
});
it('should handle unchecking then checking Include all', async () => {
setup();
const includeAllCheckbox = screen.getByTestId('migrate-to-cloud-configure-snapshot-checkbox-resource-include-all');
await userEvent.click(includeAllCheckbox);
// Include all checkbox should be unchecked
expect(includeAllCheckbox).not.toBeChecked();
// All resource type checkboxes should be unchecked
expect(screen.getByTestId('migrate-to-cloud-configure-snapshot-checkbox-resource-dashboard')).not.toBeChecked();
expect(screen.getByTestId('migrate-to-cloud-configure-snapshot-checkbox-resource-folder')).not.toBeChecked();
expect(screen.getByTestId('migrate-to-cloud-configure-snapshot-checkbox-resource-datasource')).not.toBeChecked();
// Then check all again
await userEvent.click(includeAllCheckbox);
// Include all checkbox should be checked
expect(includeAllCheckbox).toBeChecked();
// All resource type checkboxes should be checked
expect(screen.getByTestId('migrate-to-cloud-configure-snapshot-checkbox-resource-dashboard')).toBeChecked();
expect(screen.getByTestId('migrate-to-cloud-configure-snapshot-checkbox-resource-folder')).toBeChecked();
expect(screen.getByTestId('migrate-to-cloud-configure-snapshot-checkbox-resource-datasource')).toBeChecked();
});
it('should handle unchecking individual resource type', async () => {
setup();
const handleDeselection = require('./resourceDependency').handleDeselection;
// Mock return value for handleDeselection - it would deselect DASHBOARD and its dependencies
handleDeselection.mockImplementationOnce(() => new Set(['FOLDER', 'DATASOURCE']));
const dashboardCheckbox = screen.getByTestId('migrate-to-cloud-configure-snapshot-checkbox-resource-dashboard');
await userEvent.click(dashboardCheckbox);
expect(handleDeselection).toHaveBeenCalled();
// Include all checkbox should now be in indeterminate state (not checked by property).
expect(screen.getByTestId('migrate-to-cloud-configure-snapshot-checkbox-resource-include-all')).not.toBeChecked();
});
it('should handle checking individual resource type', async () => {
setup();
// First uncheck all
const includeAllCheckbox = screen.getByTestId('migrate-to-cloud-configure-snapshot-checkbox-resource-include-all');
await userEvent.click(includeAllCheckbox);
const handleSelection = require('./resourceDependency').handleSelection;
// Mock return value for handleSelection - it would select DASHBOARD and its dependencies
handleSelection.mockImplementationOnce(() => new Set(['DASHBOARD', 'FOLDER', 'DATASOURCE']));
const dashboardCheckbox = screen.getByTestId('migrate-to-cloud-configure-snapshot-checkbox-resource-dashboard');
await userEvent.click(dashboardCheckbox);
expect(handleSelection).toHaveBeenCalled();
expect(includeAllCheckbox).toBeChecked();
});
it('should call onClick with selected resource types when Build snapshot is clicked', async () => {
const { props } = setup();
const buildSnapshotButton = screen.getByTestId('migrate-to-cloud-configure-snapshot-build-snapshot-button');
await userEvent.click(buildSnapshotButton);
expect(props.onClick).toHaveBeenCalledWith(expect.arrayContaining(['DASHBOARD', 'FOLDER', 'DATASOURCE']));
});
it('should disable Build snapshot button when no types are selected', async () => {
setup();
const includeAllCheckbox = screen.getByTestId('migrate-to-cloud-configure-snapshot-checkbox-resource-include-all');
await userEvent.click(includeAllCheckbox);
const buildSnapshotButton = screen.getByTestId('migrate-to-cloud-configure-snapshot-build-snapshot-button');
expect(buildSnapshotButton).toBeDisabled();
});
it('should disable Build snapshot button when disabled prop is true', () => {
setup({ disabled: true });
const buildSnapshotButton = screen.getByTestId('migrate-to-cloud-configure-snapshot-build-snapshot-button');
expect(buildSnapshotButton).toBeDisabled();
});
it('should show spinner in button when isLoading is true', () => {
setup({ isLoading: true });
// Buttons with a spinner icon
expect(screen.getByTestId('migrate-to-cloud-configure-snapshot-build-snapshot-button').innerHTML).toContain(
'spinner'
);
});
});

View File

@ -0,0 +1,167 @@
import { useState, ChangeEvent, useEffect } from 'react';
import { Button, Icon, Stack, Checkbox, Text, Box, IconName, Space, Tooltip } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { ResourceDependencyDto } from '../api';
import { ResourceTypeId, buildDependencyMaps, handleSelection, handleDeselection } from './resourceDependency';
import { iconNameForResource, pluralizeResourceName } from './resourceInfo';
interface ConfigureSnapshotProps {
disabled: boolean;
isLoading: boolean;
onClick: (resourceTypes: ResourceTypeId[]) => void;
resourceDependencies: ResourceDependencyDto[] | never[];
}
// Manual order of resource types to display in the UI for better UX.
const alertsSubResources = [
'ALERT_RULE',
'NOTIFICATION_POLICY',
'NOTIFICATION_TEMPLATE',
'CONTACT_POINT',
'MUTE_TIMING',
] as const;
const displayOrder = [
'DASHBOARD',
'LIBRARY_ELEMENT',
'DATASOURCE',
'PLUGIN',
'FOLDER',
'ALERT_RULE_GROUP',
...alertsSubResources,
] as const;
// This guarantees that displayOrder includes all ResourceTypeId values.
type IsExhaustive = Exclude<ResourceTypeId, (typeof displayOrder)[number]> extends never ? true : false;
const hasAllResourceTypes: IsExhaustive = true; // prettier-ignore
function resourceTypeOrder(resourceTypes: ResourceTypeId[]): ResourceTypeId[] {
return hasAllResourceTypes && displayOrder.filter((type) => resourceTypes.includes(type));
}
export function ConfigureSnapshot(props: ConfigureSnapshotProps) {
const { disabled, isLoading, onClick, resourceDependencies } = props;
const [selectedTypes, setSelectedTypes] = useState<Set<ResourceTypeId>>(new Set());
const [includeAll, setIncludeAll] = useState(true);
const { dependencyMap, dependentMap } = buildDependencyMaps(resourceDependencies);
const resourceTypes = resourceTypeOrder(Array.from(dependencyMap.keys()));
// Initialize with all items selected when component mounts once.
useEffect(() => {
setSelectedTypes(new Set(resourceTypes));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleIncludeAllChange = (e: ChangeEvent<HTMLInputElement>) => {
const checked = e.target.checked;
setIncludeAll(checked);
if (checked) {
// When directly checking include all, select all other items as well.
setSelectedTypes(new Set(resourceTypes));
} else {
// When directly unchecking include all, clear all other items as well.
setSelectedTypes(new Set());
}
};
const handleTypeChange = (id: ResourceTypeId) => (e: ChangeEvent<HTMLInputElement>) => {
const updatedList = e.target.checked
? handleSelection(dependencyMap, selectedTypes, id)
: handleDeselection(dependentMap, selectedTypes, id);
setSelectedTypes(updatedList);
setIncludeAll(updatedList.size === resourceTypes.length);
};
const handleBuildSnapshot = () => {
onClick(Array.from(selectedTypes));
};
return (
<Stack direction="column" gap={3}>
<Stack direction="column" gap={1}>
<Text variant="h4">
<Stack direction="row" gap={1} alignItems="center">
<Icon name="cog" size="lg" />
<Trans i18nKey="migrate-to-cloud.configure-snapshot.title">Configure snapshot</Trans>
</Stack>
</Text>
<Text color="secondary">
<Trans i18nKey="migrate-to-cloud.configure-snapshot.description">
Select which resources you want to include in the snapshot to migrate.
</Trans>
<br />
<Text weight="bold">
<Trans i18nKey="migrate-to-cloud.configure-snapshot.description-sub-line">
Some resources may depend on others and will be automatically selected or deselected.
</Trans>
</Text>
</Text>
</Stack>
<Stack direction="column" gap={2} alignItems="flex-start">
<Stack direction="column" gap={1} alignItems="flex-start">
<Stack key="include-all" alignItems="flex-start">
<Checkbox
indeterminate={selectedTypes.size > 0 && !includeAll}
value={includeAll}
onChange={handleIncludeAllChange}
data-testid="migrate-to-cloud-configure-snapshot-checkbox-resource-include-all"
//@ts-ignore
label={
<Text variant="h5">
<Trans i18nKey="migrate-to-cloud.configure-snapshot.resource-include-all">Include all</Trans>
</Text>
}
/>
</Stack>
{resourceTypes.map((type) => (
<Stack key={type} gap={1} alignItems="center">
<Space h={alertsSubResources.includes(type as (typeof alertsSubResources)[number]) ? 2 : 0.25} />
<Checkbox
value={selectedTypes.has(type)}
onChange={handleTypeChange(type)}
data-testid={`migrate-to-cloud-configure-snapshot-checkbox-resource-${type.toLowerCase()}`}
//@ts-ignore
label={
<Stack gap={1} alignItems="center">
<Icon name={iconNameForResource(type) as IconName} size="xl" />
<Text variant="h5">{pluralizeResourceName(type) ?? type}</Text>
</Stack>
}
/>
</Stack>
))}
</Stack>
<Box display="flex" justifyContent="flex-start" alignItems="center" gap={1}>
<Button
disabled={disabled || selectedTypes.size === 0}
onClick={handleBuildSnapshot}
icon={isLoading ? 'spinner' : undefined}
data-testid="migrate-to-cloud-configure-snapshot-build-snapshot-button"
>
<Trans i18nKey="migrate-to-cloud.summary.start-migration">Build snapshot</Trans>
</Button>
<Tooltip
content={
<Trans i18nKey="migrate-to-cloud.building-snapshot.description-eta">
Creating a snapshot typically takes less than two minutes.
</Trans>
}
placement="right"
interactive={true}
>
<Icon name="info-circle" size="lg" />
</Tooltip>
</Box>
</Stack>
</Stack>
);
}

View File

@ -14,15 +14,12 @@ interface MigrationSummaryProps {
disconnectIsLoading: boolean;
onDisconnect: () => void;
showBuildSnapshot: boolean;
buildSnapshotIsLoading: boolean;
onBuildSnapshot: () => void;
showUploadSnapshot: boolean;
uploadSnapshotIsLoading: boolean;
onUploadSnapshot: () => void;
showRebuildSnapshot: boolean;
onRebuildSnapshot: () => void;
onHighlightErrors: () => void;
isHighlightErrors: boolean;
@ -42,15 +39,12 @@ export function MigrationSummary(props: MigrationSummaryProps) {
disconnectIsLoading,
onDisconnect,
showBuildSnapshot,
buildSnapshotIsLoading,
onBuildSnapshot,
showUploadSnapshot,
uploadSnapshotIsLoading,
onUploadSnapshot,
showRebuildSnapshot,
onRebuildSnapshot,
isHighlightErrors,
onHighlightErrors,
@ -125,21 +119,14 @@ export function MigrationSummary(props: MigrationSummaryProps) {
</Stack>
<Stack gap={2} wrap justifyContent="flex-end">
{showBuildSnapshot && (
<Button disabled={isBusy} onClick={onBuildSnapshot} icon={buildSnapshotIsLoading ? 'spinner' : undefined}>
<Trans i18nKey="migrate-to-cloud.summary.start-migration">Build snapshot</Trans>
</Button>
)}
{showRebuildSnapshot && (
<Button
disabled={isBusy}
onClick={onBuildSnapshot}
icon={buildSnapshotIsLoading ? 'spinner' : undefined}
disabled={isBusy || uploadSnapshotIsLoading}
onClick={onRebuildSnapshot}
variant="secondary"
data-testid="migrate-to-cloud-summary-rebuild-snapshot-button"
data-testid="migrate-to-cloud-summary-reconfigure-snapshot-button"
>
<Trans i18nKey="migrate-to-cloud.summary.rebuild-snapshot">Rebuild snapshot</Trans>
<Trans i18nKey="migrate-to-cloud.summary.rebuild-snapshot">Reconfigure snapshot</Trans>
</Button>
)}

View File

@ -11,6 +11,7 @@ import {
useCancelSnapshotMutation,
useCreateSnapshotMutation,
useDeleteSessionMutation,
useGetResourceDependenciesQuery,
useGetSessionListQuery,
useGetShapshotListQuery,
useGetSnapshotQuery,
@ -20,12 +21,13 @@ import {
import { maybeAPIError } from '../api/errors';
import { AlertWithTraceID } from '../shared/AlertWithTraceID';
import { DisconnectModal } from './DisconnectModal';
import { ConfigureSnapshot } from './ConfigureSnapshot';
import { EmptyState } from './EmptyState/EmptyState';
import { MigrationSummary } from './MigrationSummary';
import { ResourcesTable } from './ResourcesTable';
import { BuildSnapshotCTA, CreatingSnapshotCTA } from './SnapshotCTAs';
import { CreatingSnapshotCTA } from './SnapshotCTAs';
import { SupportedTypesDisclosure } from './SupportedTypesDisclosure';
import { ResourceTableItem } from './types';
import { useNotifySuccessful } from './useNotifyOnSuccess';
/**
@ -63,15 +65,6 @@ const SHOULD_POLL_STATUSES: Array<SnapshotDto['status']> = [
'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) {
@ -133,7 +126,6 @@ interface SortParams {
}
export const Page = () => {
const [disconnectModalOpen, setDisconnectModalOpen] = useState(false);
const [page, setPage] = useState(1);
const [sortParams, setSortParams] = useState<SortParams>({
column: '',
@ -141,6 +133,10 @@ export const Page = () => {
});
const [highlightErrors, setHighlightErrors] = useState(false);
const { data: resourceDependencies = { resourceDependencies: [] } } = useGetResourceDependenciesQuery();
const [reconfiguring, setReconfiguring] = useState(false);
const [lastSnapshotUid, setLastSnapshotUid] = useState<string | undefined>(undefined);
const [performCreateSnapshot, createSnapshotResult] = useCreateSnapshotMutation();
const [performUploadSnapshot, uploadSnapshotResult] = useUploadSnapshotMutation();
const [performCancelSnapshot, cancelSnapshotResult] = useCancelSnapshotMutation();
@ -158,15 +154,18 @@ export const Page = () => {
setPage(numPages);
}
}, [numPages, page]);
const [uiState, setUiState] = useState<'loading' | 'configure' | 'building' | 'built' | 'uploading' | 'uploaded'>(
'loading'
);
useNotifySuccessful(snapshot.data);
const sessionUid = session.data?.uid;
const snapshotUid = snapshot.data?.uid;
const isInitialLoading = session.isLoading;
const status = snapshot.data?.status;
const snapshotStatus = snapshot.data?.status;
// isBusy is not a loading state, but indicates that the system is doing *something*
// and all buttons should be disabled
// 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 ||
@ -175,12 +174,135 @@ export const Page = () => {
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);
// Because we don't delete the previous snapshot if it exists, we need to keep track of the last snapshot.
// When reconfiguring a snapshot, we need to pause the state machine until a new snapshot is created.
// Reconfiguration is triggered by the user clicking the "Reconfigure snapshot" button at the end of the migration.
useEffect(() => {
if (
reconfiguring &&
lastSnapshotUid !== snapshot.data?.uid &&
createSnapshotResult.isSuccess &&
createSnapshotResult.data?.uid
) {
setLastSnapshotUid(createSnapshotResult.data.uid);
setReconfiguring(false);
}
}, [
createSnapshotResult.isSuccess,
createSnapshotResult.data?.uid,
lastSnapshotUid,
snapshot.data?.uid,
setLastSnapshotUid,
setReconfiguring,
reconfiguring,
]);
// UI State Machine
useEffect(() => {
// If we don't have a session or the snapshot is still loading, don't do anything yet!
if (!sessionUid || snapshot.isLoading || snapshot.isFetching) {
return;
}
// When loading the page for the first time, we might already have a snapshot in a workable state.
if (uiState === 'loading') {
// Snapshot is being created.
if (snapshotStatus === 'CREATING') {
setUiState('building');
return;
}
// Ready to upload.
if (snapshotStatus === 'PENDING_UPLOAD') {
setUiState('built');
return;
}
// Snapshot is uploaded but still being processed by the backend.
if (['UPLOADING', 'PENDING_PROCESSING', 'PROCESSING'].includes(snapshotStatus ?? '')) {
setUiState('uploading');
return;
}
// Already uploaded with results, can reupload or reconfigure.
if (snapshotStatus === 'FINISHED') {
setUiState('uploaded');
return;
}
// Either the snapshot does not exist or is in an error state. In either case, we need to reconfigure.
if (!snapshotStatus || snapshotStatus === 'ERROR') {
setUiState('configure');
return;
}
}
// When the snapshot is being created, go to the building (spinner) state.
if (uiState === 'configure' && snapshotStatus === 'CREATING') {
setUiState('building');
return;
}
// When the snapshot has finished building, go to the built state (ready to upload + resource table "not yet uploaded").
// If we are reconfiguring, we pause the state machine until the new snapshot is actually set to PENDING_UPLOAD.
// That in turn will cause `reconfiguring` to be set to `false` which will resume the state machine.
if (!reconfiguring && uiState === 'building' && snapshotStatus === 'PENDING_UPLOAD') {
setUiState('built');
return;
}
// When the snapshot is being uploaded, go to the uploading state (spinner + resource table "in progress").
if (uiState === 'built' && (snapshotStatus === 'PROCESSING' || snapshotStatus === 'UPLOADING')) {
setUiState('uploading');
return;
}
// When the snapshot has finished uploading, go to the uploaded state (resource table "success/error").
if (uiState === 'uploading' && snapshotStatus === 'FINISHED') {
setUiState('uploaded');
return;
}
// Special case: if there's nothing to choose in the snapshot, go back to reconfiguring.
if (
!reconfiguring &&
(uiState === 'built' || uiState === 'uploaded') &&
snapshotStatus !== 'FINISHED' &&
(snapshot.data?.results?.length === 0 || snapshot.isUninitialized)
) {
setReconfiguring(true);
setUiState('configure');
return;
}
// Error handling: if we are building a snapshot and there's an error, go back to the configure state.
// Also display the error in the UI.
if (uiState === 'building' && (createSnapshotResult.error || snapshot.isError)) {
setUiState('configure');
return;
}
// Error handling: if we are uploading a snapshot and there's an error, force move to the uploaded state.
// Also display the error in the UI, so the user can reconfigure it.
if (uiState === 'uploading' && (uploadSnapshotResult.error || snapshotStatus === 'ERROR')) {
setUiState('uploaded');
return;
}
}, [
sessionUid,
snapshotStatus,
snapshot.isLoading,
snapshot.isFetching,
snapshot.isUninitialized,
snapshot.isError,
setReconfiguring,
setUiState,
uiState,
reconfiguring,
snapshot.data?.results?.length,
createSnapshotResult.error,
uploadSnapshotResult.error,
]);
const error = getError({
snapshot: snapshot.data,
@ -192,36 +314,22 @@ export const Page = () => {
disconnectSnapshotError: disconnectResult.error,
});
const handleDisconnect = useCallback(async () => {
if (sessionUid) {
performDisconnect({ uid: sessionUid });
}
}, [performDisconnect, sessionUid]);
// Action Callbacks
const handleCreateSnapshot = useCallback(
(resourceTypes: Array<ResourceTableItem['type']>) => {
if (sessionUid) {
setUiState('building');
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]);
performCreateSnapshot({
uid: sessionUid,
createSnapshotRequestDto: {
resourceTypes,
},
});
}
},
[performCreateSnapshot, sessionUid]
);
const handleUploadSnapshot = useCallback(() => {
if (sessionUid && snapshotUid) {
@ -229,14 +337,31 @@ export const Page = () => {
}
}, [performUploadSnapshot, sessionUid, snapshotUid]);
const handleRebuildSnapshot = useCallback(() => {
if (sessionUid && snapshotUid) {
setReconfiguring(true);
setUiState('configure');
}
}, [setUiState, setReconfiguring, sessionUid, snapshotUid]);
const handleCancelSnapshot = useCallback(() => {
if (sessionUid && snapshotUid) {
setUiState('configure');
performCancelSnapshot({ uid: sessionUid, snapshotUid: snapshotUid });
}
}, [performCancelSnapshot, sessionUid, snapshotUid]);
}, [performCancelSnapshot, setUiState, sessionUid, snapshotUid]);
if (isInitialLoading) {
// TODO: better loading state
const handleDisconnect = useCallback(async () => {
if (sessionUid) {
setUiState('loading');
performDisconnect({ uid: sessionUid });
}
}, [performDisconnect, setUiState, sessionUid]);
// Component Rendering
if (session.isLoading) {
return (
<div>
<Trans i18nKey="migrate-to-cloud.summary.page-loading">Loading...</Trans>
@ -249,82 +374,71 @@ export const Page = () => {
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}
/>
)}
<MigrationSummary
session={session.data}
snapshot={snapshot.data}
isBusy={isBusy}
disconnectIsLoading={disconnectResult.isLoading}
onDisconnect={handleDisconnect}
showUploadSnapshot={['built', 'uploading'].includes(uiState)}
uploadSnapshotIsLoading={uploadSnapshotResult.isLoading || uiState === 'uploading'}
onUploadSnapshot={handleUploadSnapshot}
showRebuildSnapshot={['built', 'uploading', 'uploaded'].includes(uiState)}
onRebuildSnapshot={handleRebuildSnapshot}
onHighlightErrors={() => setHighlightErrors(!highlightErrors)}
isHighlightErrors={highlightErrors}
showOnlyErrorsSwitch={['uploading', 'uploaded'].includes(uiState)}
/>
{error && (
{(['built', 'uploaded'].includes(uiState) || !!createSnapshotResult?.error) && 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}
/>
)}
{uiState === 'configure' && (
<ConfigureSnapshot
disabled={isBusy}
isLoading={isBusy}
onClick={handleCreateSnapshot}
resourceDependencies={resourceDependencies.resourceDependencies || []}
/>
)}
{showBuildingSnapshot && (
<CreatingSnapshotCTA
disabled={isBusy}
isLoading={cancelSnapshotResult.isLoading}
onClick={handleCancelSnapshot}
/>
)}
{uiState === 'building' && (
<Box display="flex" justifyContent="center" paddingY={10}>
<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>
)}
{['built', 'uploading', 'uploaded'].includes(uiState) &&
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)}
/>
</>
);
};

View File

@ -9,6 +9,7 @@ interface SnapshotCTAProps {
onClick: () => void;
}
// TODO: this can be removed with the new configuration flow merged.
export function BuildSnapshotCTA(props: SnapshotCTAProps) {
const { disabled, isLoading, onClick } = props;

View File

@ -0,0 +1,383 @@
import { ResourceDependencyDto } from '../api';
import { buildDependencyMaps, handleSelection, handleDeselection, ResourceTypeId } from './resourceDependency';
describe('resourceDependency', () => {
describe('buildDependencyMaps', () => {
it('builds empty maps when given an empty array', () => {
const dependencies: ResourceDependencyDto[] = [];
const { dependencyMap, dependentMap } = buildDependencyMaps(dependencies);
expect(dependencyMap.size).toBe(0);
expect(dependentMap.size).toBe(0);
});
it('builds correct dependency maps for a simple dependency structure', () => {
const dependencies: ResourceDependencyDto[] = [
{
resourceType: 'DASHBOARD',
dependencies: ['DATASOURCE', 'FOLDER'],
},
{
resourceType: 'LIBRARY_ELEMENT',
dependencies: ['DATASOURCE'],
},
{
resourceType: 'DATASOURCE',
dependencies: [],
},
{
resourceType: 'FOLDER',
dependencies: [],
},
];
const { dependencyMap, dependentMap } = buildDependencyMaps(dependencies);
expect(dependencyMap.size).toBe(4);
expect(dependencyMap.get('DASHBOARD')).toEqual(['DATASOURCE', 'FOLDER']);
expect(dependencyMap.get('LIBRARY_ELEMENT')).toEqual(['DATASOURCE']);
expect(dependencyMap.get('DATASOURCE')).toEqual([]);
expect(dependencyMap.get('FOLDER')).toEqual([]);
expect(dependentMap.size).toBe(2);
expect(dependentMap.get('DATASOURCE')?.sort()).toEqual(['DASHBOARD', 'LIBRARY_ELEMENT'].sort());
expect(dependentMap.get('FOLDER')).toEqual(['DASHBOARD']);
});
it('handles undefined dependencies', () => {
const dependencies: ResourceDependencyDto[] = [
{
resourceType: 'DASHBOARD',
dependencies: undefined,
},
];
const { dependencyMap, dependentMap } = buildDependencyMaps(dependencies);
expect(dependencyMap.size).toBe(1);
expect(dependencyMap.get('DASHBOARD')).toEqual([]);
expect(dependentMap.size).toBe(0);
});
// even though this is not going to be the case in the backend
it('handles circular dependencies correctly', () => {
const dependencies = [
{
resourceType: 'DASHBOARD',
dependencies: ['FOLDER'],
},
{
resourceType: 'FOLDER',
dependencies: ['DATASOURCE'],
},
{
resourceType: 'DATASOURCE',
dependencies: ['DASHBOARD'],
},
] as ResourceDependencyDto[];
const { dependencyMap, dependentMap } = buildDependencyMaps(dependencies);
expect(dependencyMap.size).toBe(3);
expect(dependencyMap.get('DASHBOARD')).toEqual(['FOLDER']);
expect(dependencyMap.get('FOLDER')).toEqual(['DATASOURCE']);
expect(dependencyMap.get('DATASOURCE')).toEqual(['DASHBOARD']);
expect(dependentMap.size).toBe(3);
expect(dependentMap.get('DASHBOARD')).toEqual(['DATASOURCE']);
expect(dependentMap.get('FOLDER')).toEqual(['DASHBOARD']);
expect(dependentMap.get('DATASOURCE')).toEqual(['FOLDER']);
});
});
describe('handleSelection', () => {
it('selects a resource with no dependency', () => {
const dependencyMap = new Map<ResourceTypeId, ResourceTypeId[]>([
['DASHBOARD', ['DATASOURCE', 'FOLDER']],
['DATASOURCE', []],
['FOLDER', []],
]);
const selectedTypes = new Set<ResourceTypeId>([]);
const result = handleSelection(dependencyMap, selectedTypes, 'DATASOURCE');
expect(result.size).toBe(1);
expect(result.has('DATASOURCE')).toBe(true);
});
it('selects a resource with dependencies and its dependencies', () => {
const dependencyMap = new Map<ResourceTypeId, ResourceTypeId[]>([
['DASHBOARD', ['DATASOURCE', 'FOLDER']],
['DATASOURCE', []],
['FOLDER', []],
]);
const selectedTypes = new Set<ResourceTypeId>([]);
const result = handleSelection(dependencyMap, selectedTypes, 'DASHBOARD');
expect(result.size).toBe(3);
expect(result.has('DASHBOARD')).toBe(true);
expect(result.has('DATASOURCE')).toBe(true);
expect(result.has('FOLDER')).toBe(true);
});
it('selects a resource that is already selected and does not select anything else', () => {
const dependencyMap = new Map<ResourceTypeId, ResourceTypeId[]>([
['DASHBOARD', ['DATASOURCE', 'FOLDER']],
['DATASOURCE', []],
['FOLDER', []],
]);
const selectedTypes = new Set<ResourceTypeId>(['DATASOURCE']);
const result = handleSelection(dependencyMap, selectedTypes, 'DASHBOARD');
expect(result.size).toBe(3);
expect(result.has('DASHBOARD')).toBe(true);
expect(result.has('DATASOURCE')).toBe(true);
expect(result.has('FOLDER')).toBe(true);
});
it('handles circular dependencies', () => {
const dependencyMap = new Map<ResourceTypeId, ResourceTypeId[]>([
['DASHBOARD', ['FOLDER']],
['FOLDER', ['DATASOURCE']],
['DATASOURCE', ['DASHBOARD']],
]);
const selectedTypes = new Set<ResourceTypeId>([]);
const result = handleSelection(dependencyMap, selectedTypes, 'DASHBOARD');
expect(result.size).toBe(3);
expect(result.has('DASHBOARD')).toBe(true);
expect(result.has('FOLDER')).toBe(true);
expect(result.has('DATASOURCE')).toBe(true);
});
it('handles deep dependency chains', () => {
const dependencyMap = new Map<ResourceTypeId, ResourceTypeId[]>([
['DASHBOARD', ['FOLDER']],
['FOLDER', ['DATASOURCE']],
['DATASOURCE', ['LIBRARY_ELEMENT']],
['LIBRARY_ELEMENT', ['PLUGIN']],
['PLUGIN', []],
]);
const selectedTypes = new Set<ResourceTypeId>([]);
const result = handleSelection(dependencyMap, selectedTypes, 'DASHBOARD');
expect(result.size).toBe(5);
expect(result.has('DASHBOARD')).toBe(true);
expect(result.has('FOLDER')).toBe(true);
expect(result.has('DATASOURCE')).toBe(true);
expect(result.has('LIBRARY_ELEMENT')).toBe(true);
expect(result.has('PLUGIN')).toBe(true);
});
it('preserves existing selections even when they are not part of the dependency chain', () => {
const dependencyMap = new Map<ResourceTypeId, ResourceTypeId[]>([
['DASHBOARD', ['FOLDER']],
['FOLDER', []],
['DATASOURCE', []],
]);
const selectedTypes = new Set<ResourceTypeId>(['DATASOURCE']);
const result = handleSelection(dependencyMap, selectedTypes, 'DASHBOARD');
expect(result.size).toBe(3);
expect(result.has('DASHBOARD')).toBe(true);
expect(result.has('FOLDER')).toBe(true);
expect(result.has('DATASOURCE')).toBe(true);
});
it('handles empty dependency maps as if there are no dependencies', () => {
const dependencyMap = new Map<ResourceTypeId, ResourceTypeId[]>();
const selectedTypes = new Set<ResourceTypeId>([]);
const result = handleSelection(dependencyMap, selectedTypes, 'DASHBOARD');
expect(result.size).toBe(1);
expect(result.has('DASHBOARD')).toBe(true);
});
it('allows selecting unknown resource types', () => {
const dependencyMap = new Map<ResourceTypeId, ResourceTypeId[]>([['DASHBOARD', ['DATASOURCE']]]);
const selectedTypes = new Set<ResourceTypeId>([]);
// @ts-ignore
const result = handleSelection(dependencyMap, selectedTypes, 'UNKNOWN_TYPE');
expect(result.size).toBe(1);
// @ts-ignore
expect(result.has('UNKNOWN_TYPE')).toBe(true);
});
});
describe('handleDeselection', () => {
it('deselects a resource with no dependents', () => {
const dependentMap = new Map<ResourceTypeId, ResourceTypeId[]>([
['DASHBOARD', []],
['DATASOURCE', ['DASHBOARD']],
]);
const selectedTypes = new Set<ResourceTypeId>(['DASHBOARD', 'DATASOURCE']);
const result = handleDeselection(dependentMap, selectedTypes, 'DASHBOARD');
expect(result.size).toBe(1);
expect(result.has('DATASOURCE')).toBe(true);
expect(result.has('DASHBOARD')).toBe(false);
});
it('deselects a resource and all resources that depend on it', () => {
const dependentMap = new Map<ResourceTypeId, ResourceTypeId[]>([
['DASHBOARD', []],
['DATASOURCE', ['DASHBOARD']],
['FOLDER', ['DASHBOARD']],
]);
const selectedTypes = new Set<ResourceTypeId>(['DASHBOARD', 'DATASOURCE', 'FOLDER']);
const result = handleDeselection(dependentMap, selectedTypes, 'DATASOURCE');
expect(result.size).toBe(1);
expect(result.has('FOLDER')).toBe(true);
expect(result.has('DASHBOARD')).toBe(false);
expect(result.has('DATASOURCE')).toBe(false);
});
it('handles already deselected resources', () => {
const dependentMap = new Map<ResourceTypeId, ResourceTypeId[]>([
['DASHBOARD', []],
['DATASOURCE', ['DASHBOARD']],
]);
const selectedTypes = new Set<ResourceTypeId>(['DATASOURCE']);
const result = handleDeselection(dependentMap, selectedTypes, 'DASHBOARD');
expect(result.size).toBe(1);
expect(result.has('DATASOURCE')).toBe(true);
expect(result.has('DASHBOARD')).toBe(false);
});
it('handles circular dependencies without infinite loops', () => {
const dependentMap = new Map<ResourceTypeId, ResourceTypeId[]>([
['DASHBOARD', ['DATASOURCE']],
['FOLDER', ['DASHBOARD']],
['DATASOURCE', ['FOLDER']],
]);
const selectedTypes = new Set<ResourceTypeId>(['DASHBOARD', 'FOLDER', 'DATASOURCE']);
const result = handleDeselection(dependentMap, selectedTypes, 'DASHBOARD');
expect(result.size).toBe(0);
});
it('handles deep dependency chains by unselecting recursively', () => {
const dependentMap = new Map<ResourceTypeId, ResourceTypeId[]>([
['PLUGIN', ['LIBRARY_ELEMENT']],
['LIBRARY_ELEMENT', ['DATASOURCE']],
['DATASOURCE', ['FOLDER']],
['FOLDER', ['DASHBOARD']],
['DASHBOARD', []],
]);
const selectedTypes = new Set<ResourceTypeId>(['PLUGIN', 'LIBRARY_ELEMENT', 'DATASOURCE', 'FOLDER', 'DASHBOARD']);
const result = handleDeselection(dependentMap, selectedTypes, 'DATASOURCE');
// After deselecting DATASOURCE, FOLDER and DASHBOARD should also be deselected
// since they depend on DATASOURCE (directly or indirectly)
// PLUGIN and LIBRARY_ELEMENT should remain
expect(result.size).toBe(2);
expect(result.has('PLUGIN')).toBe(true);
expect(result.has('LIBRARY_ELEMENT')).toBe(true);
expect(result.has('DATASOURCE')).toBe(false);
expect(result.has('FOLDER')).toBe(false);
expect(result.has('DASHBOARD')).toBe(false);
});
it('preserves unrelated selections', () => {
const dependentMap = new Map<ResourceTypeId, ResourceTypeId[]>([
['DASHBOARD', []],
['FOLDER', []],
['DATASOURCE', []],
]);
const selectedTypes = new Set<ResourceTypeId>(['DASHBOARD', 'FOLDER', 'DATASOURCE']);
const result = handleDeselection(dependentMap, selectedTypes, 'DASHBOARD');
expect(result.size).toBe(2);
expect(result.has('DATASOURCE')).toBe(true);
expect(result.has('FOLDER')).toBe(true);
expect(result.has('DASHBOARD')).toBe(false);
});
it('handles empty dependency maps as if there are no dependencies', () => {
const dependentMap = new Map<ResourceTypeId, ResourceTypeId[]>();
const selectedTypes = new Set<ResourceTypeId>(['DASHBOARD']);
const result = handleDeselection(dependentMap, selectedTypes, 'DASHBOARD');
expect(result.size).toBe(0);
});
it('allows deselecting unknown resource types', () => {
const dependentMap = new Map<ResourceTypeId, ResourceTypeId[]>([['DASHBOARD', []]]);
const selectedTypes = new Set<ResourceTypeId>(['DASHBOARD']);
// @ts-ignore - intentionally testing with an unknown type
selectedTypes.add('UNKNOWN_TYPE');
// @ts-ignore - intentionally testing with an unknown type
const result = handleDeselection(dependentMap, selectedTypes, 'UNKNOWN_TYPE');
expect(result.size).toBe(1);
expect(result.has('DASHBOARD')).toBe(true);
});
});
// Integration
it('builds the dependency maps and does selection/deselection', () => {
// 1. Create dependencies
const dependencies: ResourceDependencyDto[] = [
{
resourceType: 'DASHBOARD',
dependencies: ['DATASOURCE', 'FOLDER'],
},
{
resourceType: 'LIBRARY_ELEMENT',
dependencies: ['DATASOURCE'],
},
{
resourceType: 'DATASOURCE',
dependencies: [],
},
{
resourceType: 'FOLDER',
dependencies: [],
},
];
// 2. Build dependency maps
const { dependencyMap, dependentMap } = buildDependencyMaps(dependencies);
// 3. Start with empty selection
let selection = new Set<ResourceTypeId>();
// 4. Select DASHBOARD (should also select DATASOURCE and FOLDER)
selection = handleSelection(dependencyMap, selection, 'DASHBOARD');
expect(selection.size).toBe(3);
expect(selection.has('DASHBOARD')).toBe(true);
expect(selection.has('DATASOURCE')).toBe(true);
expect(selection.has('FOLDER')).toBe(true);
expect(selection.has('LIBRARY_ELEMENT')).toBe(false);
// 5. Select LIBRARY_ELEMENT (should already have DATASOURCE selected)
selection = handleSelection(dependencyMap, selection, 'LIBRARY_ELEMENT');
expect(selection.size).toBe(4);
expect(selection.has('LIBRARY_ELEMENT')).toBe(true);
// 6. Deselect DATASOURCE (should also deselect DASHBOARD and LIBRARY_ELEMENT)
selection = handleDeselection(dependentMap, selection, 'DATASOURCE');
expect(selection.size).toBe(1);
expect(selection.has('FOLDER')).toBe(true);
expect(selection.has('DASHBOARD')).toBe(false);
expect(selection.has('LIBRARY_ELEMENT')).toBe(false);
expect(selection.has('DATASOURCE')).toBe(false);
});
});

View File

@ -0,0 +1,82 @@
import { IconName } from '@grafana/ui';
import { ResourceDependencyDto } from '../api';
import { ResourceTableItem } from './types';
export type ResourceTypeId = ResourceTableItem['type'];
export interface ResourceType {
id: ResourceTypeId;
name: string;
icon: IconName;
}
export function buildDependencyMaps(resourceDependencies: ResourceDependencyDto[]) {
const dependencyMap = new Map<ResourceTypeId, ResourceTypeId[]>();
const dependentMap = new Map<ResourceTypeId, ResourceTypeId[]>();
for (const dependency of resourceDependencies) {
const resourceType = dependency.resourceType as ResourceTypeId;
const dependencies = (dependency.dependencies || []) as ResourceTypeId[];
dependencyMap.set(resourceType, dependencies);
// Build reverse mapping (what depends on what)
for (const dep of dependencies) {
if (!dependentMap.has(dep)) {
dependentMap.set(dep, []);
}
dependentMap.get(dep)?.push(resourceType);
}
}
return { dependencyMap, dependentMap };
}
export function handleSelection(
dependencyMap: Map<ResourceTypeId, ResourceTypeId[]>,
selectedTypes: Set<ResourceTypeId>,
resourceToSelect: ResourceTypeId
): Set<ResourceTypeId> {
const result = new Set(selectedTypes);
function selectWithDependencies(resourceType: ResourceTypeId, visited: Set<ResourceTypeId>) {
if (visited.has(resourceType)) {
return;
}
visited.add(resourceType);
result.add(resourceType);
dependencyMap.get(resourceType)?.forEach((dep) => selectWithDependencies(dep, visited));
}
selectWithDependencies(resourceToSelect, new Set());
return result;
}
export function handleDeselection(
dependentMap: Map<ResourceTypeId, ResourceTypeId[]>,
selectedTypes: Set<ResourceTypeId>,
resourceToDeselect: ResourceTypeId
): Set<ResourceTypeId> {
const result = new Set(selectedTypes);
function processDeselection(resourceType: ResourceTypeId, visited: Set<ResourceTypeId>) {
if (visited.has(resourceType)) {
return;
}
visited.add(resourceType);
result.delete(resourceType);
dependentMap.get(resourceType)?.forEach((dep) => processDeselection(dep, visited));
}
processDeselection(resourceToDeselect, new Set());
return result;
}

View File

@ -5344,6 +5344,12 @@
"link-title": "Learn about migrating other settings",
"title": "Can I move this installation to Grafana Cloud?"
},
"configure-snapshot": {
"description": "Select which resources you want to include in the snapshot to migrate.",
"description-sub-line": "Some resources may depend on others and will be automatically selected or deselected.",
"resource-include-all": "Include all",
"title": "Configure snapshot"
},
"connect-modal": {
"body-cloud-stack": "You'll also need a cloud stack. If you just signed up, we'll automatically create your first stack. If you have an account, you'll need to select or create a stack.",
"body-get-started": "To get started, you'll need a Grafana.com account.",
@ -5533,7 +5539,7 @@
"disconnect": "Disconnect",
"errored-resource-count": "Errors",
"page-loading": "Loading...",
"rebuild-snapshot": "Rebuild snapshot",
"rebuild-snapshot": "Reconfigure snapshot",
"show-errors": "Only view errors",
"snapshot-date": "Snapshot timestamp",
"snapshot-not-created": "Not yet created",