From b58b6e828ea6a79e960f674c0944bfba67ff9c7e Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Thu, 10 Apr 2025 14:14:13 +0200 Subject: [PATCH] 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 --- .betterer.results | 8 + e2e/various-suite/migrate-to-cloud.spec.ts | 81 +++- .../onprem/ConfigureSnapshot.test.tsx | 175 ++++++++ .../onprem/ConfigureSnapshot.tsx | 167 ++++++++ .../onprem/MigrationSummary.tsx | 25 +- .../features/migrate-to-cloud/onprem/Page.tsx | 352 ++++++++++------ .../migrate-to-cloud/onprem/SnapshotCTAs.tsx | 1 + .../onprem/resourceDependency.test.ts | 383 ++++++++++++++++++ .../onprem/resourceDependency.ts | 82 ++++ public/locales/en-US/grafana.json | 8 +- 10 files changed, 1130 insertions(+), 152 deletions(-) create mode 100644 public/app/features/migrate-to-cloud/onprem/ConfigureSnapshot.test.tsx create mode 100644 public/app/features/migrate-to-cloud/onprem/ConfigureSnapshot.tsx create mode 100644 public/app/features/migrate-to-cloud/onprem/resourceDependency.test.ts create mode 100644 public/app/features/migrate-to-cloud/onprem/resourceDependency.ts diff --git a/.betterer.results b/.betterer.results index dd37a8ffa5e..5536605dcd4 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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 ", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "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"] ], diff --git a/e2e/various-suite/migrate-to-cloud.spec.ts b/e2e/various-suite/migrate-to-cloud.spec.ts index fe32c2b74d5..d464060e0ab 100644 --- a/e2e/various-suite/migrate-to-cloud.spec.ts +++ b/e2e/various-suite/migrate-to-cloud.spec.ts @@ -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. diff --git a/public/app/features/migrate-to-cloud/onprem/ConfigureSnapshot.test.tsx b/public/app/features/migrate-to-cloud/onprem/ConfigureSnapshot.test.tsx new file mode 100644 index 00000000000..5871eb98e99 --- /dev/null +++ b/public/app/features/migrate-to-cloud/onprem/ConfigureSnapshot.test.tsx @@ -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>) => { + const props = { + disabled: false, + isLoading: false, + onClick: jest.fn() as jest.Mock, + resourceDependencies: mockResourceDependencies, + ...propOverrides, + }; + + return { + ...render(), + 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' + ); + }); +}); diff --git a/public/app/features/migrate-to-cloud/onprem/ConfigureSnapshot.tsx b/public/app/features/migrate-to-cloud/onprem/ConfigureSnapshot.tsx new file mode 100644 index 00000000000..a0ca8ca7985 --- /dev/null +++ b/public/app/features/migrate-to-cloud/onprem/ConfigureSnapshot.tsx @@ -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 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>(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) => { + 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) => { + 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 ( + + + + + + Configure snapshot + + + + + Select which resources you want to include in the snapshot to migrate. + +
+ + + Some resources may depend on others and will be automatically selected or deselected. + + +
+
+ + + + + 0 && !includeAll} + value={includeAll} + onChange={handleIncludeAllChange} + data-testid="migrate-to-cloud-configure-snapshot-checkbox-resource-include-all" + //@ts-ignore + label={ + + Include all + + } + /> + + + {resourceTypes.map((type) => ( + + + + + {pluralizeResourceName(type) ?? type} + + } + /> + + ))} + + + + + + + Creating a snapshot typically takes less than two minutes. +
+ } + placement="right" + interactive={true} + > + + + + + + ); +} diff --git a/public/app/features/migrate-to-cloud/onprem/MigrationSummary.tsx b/public/app/features/migrate-to-cloud/onprem/MigrationSummary.tsx index 270cc8729a6..c791f0ea44d 100644 --- a/public/app/features/migrate-to-cloud/onprem/MigrationSummary.tsx +++ b/public/app/features/migrate-to-cloud/onprem/MigrationSummary.tsx @@ -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) { - {showBuildSnapshot && ( - - )} - {showRebuildSnapshot && ( )} diff --git a/public/app/features/migrate-to-cloud/onprem/Page.tsx b/public/app/features/migrate-to-cloud/onprem/Page.tsx index 9a62636db55..0331310888c 100644 --- a/public/app/features/migrate-to-cloud/onprem/Page.tsx +++ b/public/app/features/migrate-to-cloud/onprem/Page.tsx @@ -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 = [ '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 SNAPSHOT_RESOURCES_HAVE_ERROR_STATUSES: Array = [ - '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({ 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(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) => { + 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 (
Loading... @@ -249,82 +374,71 @@ export const Page = () => { return ( <> - {session.data && ( - setHighlightErrors(!highlightErrors)} - isHighlightErrors={highlightErrors} - showOnlyErrorsSwitch={showOnlyErrorsSwitch} - /> - )} + setHighlightErrors(!highlightErrors)} + isHighlightErrors={highlightErrors} + showOnlyErrorsSwitch={['uploading', 'uploaded'].includes(uiState)} + /> - {error && ( + {(['built', 'uploaded'].includes(uiState) || !!createSnapshotResult?.error) && error && ( {error.body} )} - {(showBuildSnapshot || showBuildingSnapshot) && ( - - {showBuildSnapshot && ( - - )} + {uiState === 'configure' && ( + + )} - {showBuildingSnapshot && ( - - )} + {uiState === 'building' && ( + + )} - {snapshot.data?.results && snapshot.data.results.length > 0 && ( - - { - 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, - }); - } - }} - /> - - - )} + {['built', 'uploading', 'uploaded'].includes(uiState) && + snapshot.data?.results && + snapshot.data?.results.length > 0 && ( + + { + 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, + }); + } + }} + /> + + + )} - - setDisconnectModalOpen(false)} - /> ); }; diff --git a/public/app/features/migrate-to-cloud/onprem/SnapshotCTAs.tsx b/public/app/features/migrate-to-cloud/onprem/SnapshotCTAs.tsx index e9b84aef4f4..92f9cb58cab 100644 --- a/public/app/features/migrate-to-cloud/onprem/SnapshotCTAs.tsx +++ b/public/app/features/migrate-to-cloud/onprem/SnapshotCTAs.tsx @@ -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; diff --git a/public/app/features/migrate-to-cloud/onprem/resourceDependency.test.ts b/public/app/features/migrate-to-cloud/onprem/resourceDependency.test.ts new file mode 100644 index 00000000000..08f9db78a5e --- /dev/null +++ b/public/app/features/migrate-to-cloud/onprem/resourceDependency.test.ts @@ -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([ + ['DASHBOARD', ['DATASOURCE', 'FOLDER']], + ['DATASOURCE', []], + ['FOLDER', []], + ]); + + const selectedTypes = new Set([]); + 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([ + ['DASHBOARD', ['DATASOURCE', 'FOLDER']], + ['DATASOURCE', []], + ['FOLDER', []], + ]); + + const selectedTypes = new Set([]); + 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([ + ['DASHBOARD', ['DATASOURCE', 'FOLDER']], + ['DATASOURCE', []], + ['FOLDER', []], + ]); + + const selectedTypes = new Set(['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([ + ['DASHBOARD', ['FOLDER']], + ['FOLDER', ['DATASOURCE']], + ['DATASOURCE', ['DASHBOARD']], + ]); + + const selectedTypes = new Set([]); + 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([ + ['DASHBOARD', ['FOLDER']], + ['FOLDER', ['DATASOURCE']], + ['DATASOURCE', ['LIBRARY_ELEMENT']], + ['LIBRARY_ELEMENT', ['PLUGIN']], + ['PLUGIN', []], + ]); + + const selectedTypes = new Set([]); + 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([ + ['DASHBOARD', ['FOLDER']], + ['FOLDER', []], + ['DATASOURCE', []], + ]); + + const selectedTypes = new Set(['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(); + const selectedTypes = new Set([]); + 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([['DASHBOARD', ['DATASOURCE']]]); + + const selectedTypes = new Set([]); + // @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([ + ['DASHBOARD', []], + ['DATASOURCE', ['DASHBOARD']], + ]); + + const selectedTypes = new Set(['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([ + ['DASHBOARD', []], + ['DATASOURCE', ['DASHBOARD']], + ['FOLDER', ['DASHBOARD']], + ]); + + const selectedTypes = new Set(['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([ + ['DASHBOARD', []], + ['DATASOURCE', ['DASHBOARD']], + ]); + + const selectedTypes = new Set(['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([ + ['DASHBOARD', ['DATASOURCE']], + ['FOLDER', ['DASHBOARD']], + ['DATASOURCE', ['FOLDER']], + ]); + + const selectedTypes = new Set(['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([ + ['PLUGIN', ['LIBRARY_ELEMENT']], + ['LIBRARY_ELEMENT', ['DATASOURCE']], + ['DATASOURCE', ['FOLDER']], + ['FOLDER', ['DASHBOARD']], + ['DASHBOARD', []], + ]); + + const selectedTypes = new Set(['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([ + ['DASHBOARD', []], + ['FOLDER', []], + ['DATASOURCE', []], + ]); + + const selectedTypes = new Set(['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(); + const selectedTypes = new Set(['DASHBOARD']); + const result = handleDeselection(dependentMap, selectedTypes, 'DASHBOARD'); + + expect(result.size).toBe(0); + }); + + it('allows deselecting unknown resource types', () => { + const dependentMap = new Map([['DASHBOARD', []]]); + + const selectedTypes = new Set(['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(); + + // 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); + }); +}); diff --git a/public/app/features/migrate-to-cloud/onprem/resourceDependency.ts b/public/app/features/migrate-to-cloud/onprem/resourceDependency.ts new file mode 100644 index 00000000000..41c4a7b5a53 --- /dev/null +++ b/public/app/features/migrate-to-cloud/onprem/resourceDependency.ts @@ -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(); + const dependentMap = new Map(); + + 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, + selectedTypes: Set, + resourceToSelect: ResourceTypeId +): Set { + const result = new Set(selectedTypes); + + function selectWithDependencies(resourceType: ResourceTypeId, visited: Set) { + 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, + selectedTypes: Set, + resourceToDeselect: ResourceTypeId +): Set { + const result = new Set(selectedTypes); + + function processDeselection(resourceType: ResourceTypeId, visited: Set) { + 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; +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index f94a028cb50..8896283e525 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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",