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:
@@ -2570,10 +2570,18 @@ exports[`better eslint`] = {
|
|||||||
"public/app/features/migrate-to-cloud/api/index.ts:5381": [
|
"public/app/features/migrate-to-cloud/api/index.ts:5381": [
|
||||||
[0, 0, 0, "Do not use export all (\`export * from ...\`)", "0"]
|
[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": [
|
"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 />", "0"],
|
||||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
[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": [
|
"public/app/features/migrate-to-cloud/onprem/useNotifyOnSuccess.tsx:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { e2e } from '../utils';
|
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.
|
// 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', () => {
|
describe('with mocked calls to the API backend', () => {
|
||||||
afterEach(() => {
|
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.
|
// 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']);
|
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`, {
|
cy.intercept('POST', `/api/cloudmigration/migration/${SESSION_UID}/snapshot`, {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: {
|
body: {
|
||||||
@@ -121,7 +170,7 @@ describe.skip('Migrate to Cloud (On-prem)', () => {
|
|||||||
let getSnapshotCalled = false;
|
let getSnapshotCalled = false;
|
||||||
cy.intercept(
|
cy.intercept(
|
||||||
'GET',
|
'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) => {
|
||||||
if (!getSnapshotCalled) {
|
if (!getSnapshotCalled) {
|
||||||
getSnapshotCalled = true;
|
getSnapshotCalled = true;
|
||||||
@@ -172,6 +221,13 @@ describe.skip('Migrate to Cloud (On-prem)', () => {
|
|||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
}).as('uploadSnapshot');
|
}).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*`, {
|
cy.intercept('GET', `/api/cloudmigration/migration/${SESSION_UID}/snapshots?page=1&limit=1*`, {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: {
|
body: {
|
||||||
@@ -187,14 +243,11 @@ describe.skip('Migrate to Cloud (On-prem)', () => {
|
|||||||
},
|
},
|
||||||
}).as('getSnapshotListUploading');
|
}).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.
|
// Simulate the snapshot being uploaded, the frontend will keep polling until the snapshot is either finished or errored.
|
||||||
let getSnapshotUploadingCalls = 0;
|
let getSnapshotUploadingCalls = 0;
|
||||||
cy.intercept(
|
cy.intercept(
|
||||||
'GET',
|
'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) => {
|
||||||
req.reply((res) => {
|
req.reply((res) => {
|
||||||
if (getSnapshotUploadingCalls <= 1) {
|
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.
|
// Wait for the request to kickstart the upload and then wait until it is finished.
|
||||||
cy.wait(['@uploadSnapshot', '@getSnapshotListUploading', '@getSnapshotUploading']);
|
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.
|
// At least some of the items are marked with "Uploaded to cloud" status.
|
||||||
cy.contains('Uploaded to cloud').should('be.visible');
|
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();
|
cy.get('[data-testid="migrate-to-cloud-configure-snapshot-build-snapshot-button"]').should('be.visible').click();
|
||||||
|
|
||||||
// And the rebuild button should be visible.
|
// 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,
|
// 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.
|
// similarly to what the SMTP (tester) server does.
|
||||||
|
|||||||
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,15 +14,12 @@ interface MigrationSummaryProps {
|
|||||||
disconnectIsLoading: boolean;
|
disconnectIsLoading: boolean;
|
||||||
onDisconnect: () => void;
|
onDisconnect: () => void;
|
||||||
|
|
||||||
showBuildSnapshot: boolean;
|
|
||||||
buildSnapshotIsLoading: boolean;
|
|
||||||
onBuildSnapshot: () => void;
|
|
||||||
|
|
||||||
showUploadSnapshot: boolean;
|
showUploadSnapshot: boolean;
|
||||||
uploadSnapshotIsLoading: boolean;
|
uploadSnapshotIsLoading: boolean;
|
||||||
onUploadSnapshot: () => void;
|
onUploadSnapshot: () => void;
|
||||||
|
|
||||||
showRebuildSnapshot: boolean;
|
showRebuildSnapshot: boolean;
|
||||||
|
onRebuildSnapshot: () => void;
|
||||||
|
|
||||||
onHighlightErrors: () => void;
|
onHighlightErrors: () => void;
|
||||||
isHighlightErrors: boolean;
|
isHighlightErrors: boolean;
|
||||||
@@ -42,15 +39,12 @@ export function MigrationSummary(props: MigrationSummaryProps) {
|
|||||||
disconnectIsLoading,
|
disconnectIsLoading,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
|
|
||||||
showBuildSnapshot,
|
|
||||||
buildSnapshotIsLoading,
|
|
||||||
onBuildSnapshot,
|
|
||||||
|
|
||||||
showUploadSnapshot,
|
showUploadSnapshot,
|
||||||
uploadSnapshotIsLoading,
|
uploadSnapshotIsLoading,
|
||||||
onUploadSnapshot,
|
onUploadSnapshot,
|
||||||
|
|
||||||
showRebuildSnapshot,
|
showRebuildSnapshot,
|
||||||
|
onRebuildSnapshot,
|
||||||
|
|
||||||
isHighlightErrors,
|
isHighlightErrors,
|
||||||
onHighlightErrors,
|
onHighlightErrors,
|
||||||
@@ -125,21 +119,14 @@ export function MigrationSummary(props: MigrationSummaryProps) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack gap={2} wrap justifyContent="flex-end">
|
<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 && (
|
{showRebuildSnapshot && (
|
||||||
<Button
|
<Button
|
||||||
disabled={isBusy}
|
disabled={isBusy || uploadSnapshotIsLoading}
|
||||||
onClick={onBuildSnapshot}
|
onClick={onRebuildSnapshot}
|
||||||
icon={buildSnapshotIsLoading ? 'spinner' : undefined}
|
|
||||||
variant="secondary"
|
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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
useCancelSnapshotMutation,
|
useCancelSnapshotMutation,
|
||||||
useCreateSnapshotMutation,
|
useCreateSnapshotMutation,
|
||||||
useDeleteSessionMutation,
|
useDeleteSessionMutation,
|
||||||
|
useGetResourceDependenciesQuery,
|
||||||
useGetSessionListQuery,
|
useGetSessionListQuery,
|
||||||
useGetShapshotListQuery,
|
useGetShapshotListQuery,
|
||||||
useGetSnapshotQuery,
|
useGetSnapshotQuery,
|
||||||
@@ -20,12 +21,13 @@ import {
|
|||||||
import { maybeAPIError } from '../api/errors';
|
import { maybeAPIError } from '../api/errors';
|
||||||
import { AlertWithTraceID } from '../shared/AlertWithTraceID';
|
import { AlertWithTraceID } from '../shared/AlertWithTraceID';
|
||||||
|
|
||||||
import { DisconnectModal } from './DisconnectModal';
|
import { ConfigureSnapshot } from './ConfigureSnapshot';
|
||||||
import { EmptyState } from './EmptyState/EmptyState';
|
import { EmptyState } from './EmptyState/EmptyState';
|
||||||
import { MigrationSummary } from './MigrationSummary';
|
import { MigrationSummary } from './MigrationSummary';
|
||||||
import { ResourcesTable } from './ResourcesTable';
|
import { ResourcesTable } from './ResourcesTable';
|
||||||
import { BuildSnapshotCTA, CreatingSnapshotCTA } from './SnapshotCTAs';
|
import { CreatingSnapshotCTA } from './SnapshotCTAs';
|
||||||
import { SupportedTypesDisclosure } from './SupportedTypesDisclosure';
|
import { SupportedTypesDisclosure } from './SupportedTypesDisclosure';
|
||||||
|
import { ResourceTableItem } from './types';
|
||||||
import { useNotifySuccessful } from './useNotifyOnSuccess';
|
import { useNotifySuccessful } from './useNotifyOnSuccess';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,15 +65,6 @@ const SHOULD_POLL_STATUSES: Array<SnapshotDto['status']> = [
|
|||||||
'PROCESSING',
|
'PROCESSING',
|
||||||
];
|
];
|
||||||
|
|
||||||
const SNAPSHOT_REBUILD_STATUSES: Array<SnapshotDto['status']> = ['PENDING_UPLOAD', 'FINISHED', 'ERROR', 'UNKNOWN'];
|
|
||||||
const SNAPSHOT_BUILDING_STATUSES: Array<SnapshotDto['status']> = ['INITIALIZING', 'CREATING'];
|
|
||||||
const SNAPSHOT_UPLOADING_STATUSES: Array<SnapshotDto['status']> = ['UPLOADING', 'PENDING_PROCESSING', 'PROCESSING'];
|
|
||||||
const SNAPSHOT_RESOURCES_HAVE_ERROR_STATUSES: Array<SnapshotDto['status']> = [
|
|
||||||
'PROCESSING',
|
|
||||||
'PENDING_PROCESSING',
|
|
||||||
'FINISHED',
|
|
||||||
];
|
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
function useGetLatestSnapshot(sessionUid?: string, page = 1, sortParams?: SortParams, showErrors = false) {
|
function useGetLatestSnapshot(sessionUid?: string, page = 1, sortParams?: SortParams, showErrors = false) {
|
||||||
@@ -133,7 +126,6 @@ interface SortParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Page = () => {
|
export const Page = () => {
|
||||||
const [disconnectModalOpen, setDisconnectModalOpen] = useState(false);
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [sortParams, setSortParams] = useState<SortParams>({
|
const [sortParams, setSortParams] = useState<SortParams>({
|
||||||
column: '',
|
column: '',
|
||||||
@@ -141,6 +133,10 @@ export const Page = () => {
|
|||||||
});
|
});
|
||||||
const [highlightErrors, setHighlightErrors] = useState(false);
|
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 [performCreateSnapshot, createSnapshotResult] = useCreateSnapshotMutation();
|
||||||
const [performUploadSnapshot, uploadSnapshotResult] = useUploadSnapshotMutation();
|
const [performUploadSnapshot, uploadSnapshotResult] = useUploadSnapshotMutation();
|
||||||
const [performCancelSnapshot, cancelSnapshotResult] = useCancelSnapshotMutation();
|
const [performCancelSnapshot, cancelSnapshotResult] = useCancelSnapshotMutation();
|
||||||
@@ -158,15 +154,18 @@ export const Page = () => {
|
|||||||
setPage(numPages);
|
setPage(numPages);
|
||||||
}
|
}
|
||||||
}, [numPages, page]);
|
}, [numPages, page]);
|
||||||
|
|
||||||
|
const [uiState, setUiState] = useState<'loading' | 'configure' | 'building' | 'built' | 'uploading' | 'uploaded'>(
|
||||||
|
'loading'
|
||||||
|
);
|
||||||
|
|
||||||
useNotifySuccessful(snapshot.data);
|
useNotifySuccessful(snapshot.data);
|
||||||
|
|
||||||
const sessionUid = session.data?.uid;
|
const sessionUid = session.data?.uid;
|
||||||
const snapshotUid = snapshot.data?.uid;
|
const snapshotUid = snapshot.data?.uid;
|
||||||
const isInitialLoading = session.isLoading;
|
const snapshotStatus = snapshot.data?.status;
|
||||||
const status = snapshot.data?.status;
|
|
||||||
|
|
||||||
// isBusy is not a loading state, but indicates that the system is doing *something*
|
// isBusy is not a loading state, but indicates that the system is doing *something* and all buttons should be disabled.
|
||||||
// and all buttons should be disabled
|
|
||||||
const isBusy =
|
const isBusy =
|
||||||
createSnapshotResult.isLoading ||
|
createSnapshotResult.isLoading ||
|
||||||
uploadSnapshotResult.isLoading ||
|
uploadSnapshotResult.isLoading ||
|
||||||
@@ -175,12 +174,135 @@ export const Page = () => {
|
|||||||
snapshot.isLoading ||
|
snapshot.isLoading ||
|
||||||
disconnectResult.isLoading;
|
disconnectResult.isLoading;
|
||||||
|
|
||||||
const showBuildSnapshot = !snapshot.isError && !snapshot.isLoading && !snapshot.data;
|
// Because we don't delete the previous snapshot if it exists, we need to keep track of the last snapshot.
|
||||||
const showBuildingSnapshot = SNAPSHOT_BUILDING_STATUSES.includes(status);
|
// When reconfiguring a snapshot, we need to pause the state machine until a new snapshot is created.
|
||||||
const showUploadSnapshot =
|
// Reconfiguration is triggered by the user clicking the "Reconfigure snapshot" button at the end of the migration.
|
||||||
!snapshot.isError && (status === 'PENDING_UPLOAD' || SNAPSHOT_UPLOADING_STATUSES.includes(status));
|
useEffect(() => {
|
||||||
const showRebuildSnapshot = SNAPSHOT_REBUILD_STATUSES.includes(status);
|
if (
|
||||||
const showOnlyErrorsSwitch = SNAPSHOT_RESOURCES_HAVE_ERROR_STATUSES.includes(status);
|
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({
|
const error = getError({
|
||||||
snapshot: snapshot.data,
|
snapshot: snapshot.data,
|
||||||
@@ -192,36 +314,22 @@ export const Page = () => {
|
|||||||
disconnectSnapshotError: disconnectResult.error,
|
disconnectSnapshotError: disconnectResult.error,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDisconnect = useCallback(async () => {
|
// Action Callbacks
|
||||||
if (sessionUid) {
|
const handleCreateSnapshot = useCallback(
|
||||||
performDisconnect({ uid: sessionUid });
|
(resourceTypes: Array<ResourceTableItem['type']>) => {
|
||||||
}
|
if (sessionUid) {
|
||||||
}, [performDisconnect, sessionUid]);
|
setUiState('building');
|
||||||
|
|
||||||
const handleCreateSnapshot = useCallback(() => {
|
performCreateSnapshot({
|
||||||
if (sessionUid) {
|
uid: sessionUid,
|
||||||
performCreateSnapshot({
|
createSnapshotRequestDto: {
|
||||||
uid: sessionUid,
|
resourceTypes,
|
||||||
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',
|
[performCreateSnapshot, sessionUid]
|
||||||
'DATASOURCE',
|
);
|
||||||
'FOLDER',
|
|
||||||
'LIBRARY_ELEMENT',
|
|
||||||
'ALERT_RULE',
|
|
||||||
'ALERT_RULE_GROUP',
|
|
||||||
'CONTACT_POINT',
|
|
||||||
'NOTIFICATION_POLICY',
|
|
||||||
'NOTIFICATION_TEMPLATE',
|
|
||||||
'MUTE_TIMING',
|
|
||||||
'PLUGIN',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [performCreateSnapshot, sessionUid]);
|
|
||||||
|
|
||||||
const handleUploadSnapshot = useCallback(() => {
|
const handleUploadSnapshot = useCallback(() => {
|
||||||
if (sessionUid && snapshotUid) {
|
if (sessionUid && snapshotUid) {
|
||||||
@@ -229,14 +337,31 @@ export const Page = () => {
|
|||||||
}
|
}
|
||||||
}, [performUploadSnapshot, sessionUid, snapshotUid]);
|
}, [performUploadSnapshot, sessionUid, snapshotUid]);
|
||||||
|
|
||||||
|
const handleRebuildSnapshot = useCallback(() => {
|
||||||
|
if (sessionUid && snapshotUid) {
|
||||||
|
setReconfiguring(true);
|
||||||
|
setUiState('configure');
|
||||||
|
}
|
||||||
|
}, [setUiState, setReconfiguring, sessionUid, snapshotUid]);
|
||||||
|
|
||||||
const handleCancelSnapshot = useCallback(() => {
|
const handleCancelSnapshot = useCallback(() => {
|
||||||
if (sessionUid && snapshotUid) {
|
if (sessionUid && snapshotUid) {
|
||||||
|
setUiState('configure');
|
||||||
|
|
||||||
performCancelSnapshot({ uid: sessionUid, snapshotUid: snapshotUid });
|
performCancelSnapshot({ uid: sessionUid, snapshotUid: snapshotUid });
|
||||||
}
|
}
|
||||||
}, [performCancelSnapshot, sessionUid, snapshotUid]);
|
}, [performCancelSnapshot, setUiState, sessionUid, snapshotUid]);
|
||||||
|
|
||||||
if (isInitialLoading) {
|
const handleDisconnect = useCallback(async () => {
|
||||||
// TODO: better loading state
|
if (sessionUid) {
|
||||||
|
setUiState('loading');
|
||||||
|
|
||||||
|
performDisconnect({ uid: sessionUid });
|
||||||
|
}
|
||||||
|
}, [performDisconnect, setUiState, sessionUid]);
|
||||||
|
|
||||||
|
// Component Rendering
|
||||||
|
if (session.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Trans i18nKey="migrate-to-cloud.summary.page-loading">Loading...</Trans>
|
<Trans i18nKey="migrate-to-cloud.summary.page-loading">Loading...</Trans>
|
||||||
@@ -249,82 +374,71 @@ export const Page = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack direction="column" gap={2}>
|
<Stack direction="column" gap={2}>
|
||||||
{session.data && (
|
<MigrationSummary
|
||||||
<MigrationSummary
|
session={session.data}
|
||||||
session={session.data}
|
snapshot={snapshot.data}
|
||||||
snapshot={snapshot.data}
|
isBusy={isBusy}
|
||||||
isBusy={isBusy}
|
disconnectIsLoading={disconnectResult.isLoading}
|
||||||
disconnectIsLoading={disconnectResult.isLoading}
|
onDisconnect={handleDisconnect}
|
||||||
onDisconnect={handleDisconnect}
|
showUploadSnapshot={['built', 'uploading'].includes(uiState)}
|
||||||
showBuildSnapshot={showBuildSnapshot}
|
uploadSnapshotIsLoading={uploadSnapshotResult.isLoading || uiState === 'uploading'}
|
||||||
buildSnapshotIsLoading={createSnapshotResult.isLoading}
|
onUploadSnapshot={handleUploadSnapshot}
|
||||||
onBuildSnapshot={handleCreateSnapshot}
|
showRebuildSnapshot={['built', 'uploading', 'uploaded'].includes(uiState)}
|
||||||
showUploadSnapshot={showUploadSnapshot}
|
onRebuildSnapshot={handleRebuildSnapshot}
|
||||||
uploadSnapshotIsLoading={uploadSnapshotResult.isLoading || SNAPSHOT_UPLOADING_STATUSES.includes(status)}
|
onHighlightErrors={() => setHighlightErrors(!highlightErrors)}
|
||||||
onUploadSnapshot={handleUploadSnapshot}
|
isHighlightErrors={highlightErrors}
|
||||||
showRebuildSnapshot={showRebuildSnapshot}
|
showOnlyErrorsSwitch={['uploading', 'uploaded'].includes(uiState)}
|
||||||
onHighlightErrors={() => setHighlightErrors(!highlightErrors)}
|
/>
|
||||||
isHighlightErrors={highlightErrors}
|
|
||||||
showOnlyErrorsSwitch={showOnlyErrorsSwitch}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
{(['built', 'uploaded'].includes(uiState) || !!createSnapshotResult?.error) && error && (
|
||||||
<AlertWithTraceID severity={error.severity} title={error.title} error={error.error}>
|
<AlertWithTraceID severity={error.severity} title={error.title} error={error.error}>
|
||||||
<Text element="p">{error.body}</Text>
|
<Text element="p">{error.body}</Text>
|
||||||
</AlertWithTraceID>
|
</AlertWithTraceID>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(showBuildSnapshot || showBuildingSnapshot) && (
|
{uiState === 'configure' && (
|
||||||
<Box display="flex" justifyContent="center" paddingY={10}>
|
<ConfigureSnapshot
|
||||||
{showBuildSnapshot && (
|
disabled={isBusy}
|
||||||
<BuildSnapshotCTA
|
isLoading={isBusy}
|
||||||
disabled={isBusy}
|
onClick={handleCreateSnapshot}
|
||||||
isLoading={createSnapshotResult.isLoading}
|
resourceDependencies={resourceDependencies.resourceDependencies || []}
|
||||||
onClick={handleCreateSnapshot}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{showBuildingSnapshot && (
|
{uiState === 'building' && (
|
||||||
<CreatingSnapshotCTA
|
<Box display="flex" justifyContent="center" paddingY={10}>
|
||||||
disabled={isBusy}
|
<CreatingSnapshotCTA
|
||||||
isLoading={cancelSnapshotResult.isLoading}
|
disabled={isBusy}
|
||||||
onClick={handleCancelSnapshot}
|
isLoading={cancelSnapshotResult.isLoading}
|
||||||
/>
|
onClick={handleCancelSnapshot}
|
||||||
)}
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{snapshot.data?.results && snapshot.data.results.length > 0 && (
|
{['built', 'uploading', 'uploaded'].includes(uiState) &&
|
||||||
<Stack gap={4} direction="column">
|
snapshot.data?.results &&
|
||||||
<ResourcesTable
|
snapshot.data?.results.length > 0 && (
|
||||||
resources={snapshot.data.results}
|
<Stack gap={4} direction="column">
|
||||||
localPlugins={localPlugins}
|
<ResourcesTable
|
||||||
onChangePage={setPage}
|
resources={snapshot.data.results}
|
||||||
numberOfPages={numPages}
|
localPlugins={localPlugins}
|
||||||
page={page}
|
onChangePage={setPage}
|
||||||
onChangeSort={(a) => {
|
numberOfPages={numPages}
|
||||||
const order = a.sortBy[0]?.desc === undefined ? undefined : a.sortBy[0]?.desc ? 'desc' : 'asc';
|
page={page}
|
||||||
if (sortParams.column !== a.sortBy[0]?.id || order !== sortParams.order) {
|
onChangeSort={(a) => {
|
||||||
setSortParams({
|
const order = a.sortBy[0]?.desc === undefined ? undefined : a.sortBy[0]?.desc ? 'desc' : 'asc';
|
||||||
column: a.sortBy[0]?.id,
|
if (sortParams.column !== a.sortBy[0]?.id || order !== sortParams.order) {
|
||||||
order: order,
|
setSortParams({
|
||||||
});
|
column: a.sortBy[0]?.id,
|
||||||
}
|
order: order,
|
||||||
}}
|
});
|
||||||
/>
|
}
|
||||||
<SupportedTypesDisclosure />
|
}}
|
||||||
</Stack>
|
/>
|
||||||
)}
|
<SupportedTypesDisclosure />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<DisconnectModal
|
|
||||||
isOpen={disconnectModalOpen}
|
|
||||||
isLoading={disconnectResult.isLoading}
|
|
||||||
isError={disconnectResult.isError}
|
|
||||||
onDisconnectConfirm={handleDisconnect}
|
|
||||||
onDismiss={() => setDisconnectModalOpen(false)}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface SnapshotCTAProps {
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: this can be removed with the new configuration flow merged.
|
||||||
export function BuildSnapshotCTA(props: SnapshotCTAProps) {
|
export function BuildSnapshotCTA(props: SnapshotCTAProps) {
|
||||||
const { disabled, isLoading, onClick } = props;
|
const { disabled, isLoading, onClick } = props;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -5344,6 +5344,12 @@
|
|||||||
"link-title": "Learn about migrating other settings",
|
"link-title": "Learn about migrating other settings",
|
||||||
"title": "Can I move this installation to Grafana Cloud?"
|
"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": {
|
"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-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.",
|
"body-get-started": "To get started, you'll need a Grafana.com account.",
|
||||||
@@ -5533,7 +5539,7 @@
|
|||||||
"disconnect": "Disconnect",
|
"disconnect": "Disconnect",
|
||||||
"errored-resource-count": "Errors",
|
"errored-resource-count": "Errors",
|
||||||
"page-loading": "Loading...",
|
"page-loading": "Loading...",
|
||||||
"rebuild-snapshot": "Rebuild snapshot",
|
"rebuild-snapshot": "Reconfigure snapshot",
|
||||||
"show-errors": "Only view errors",
|
"show-errors": "Only view errors",
|
||||||
"snapshot-date": "Snapshot timestamp",
|
"snapshot-date": "Snapshot timestamp",
|
||||||
"snapshot-not-created": "Not yet created",
|
"snapshot-not-created": "Not yet created",
|
||||||
|
|||||||
Reference in New Issue
Block a user