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",