mirror of https://github.com/grafana/grafana.git
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:
parent
f201fbc8d2
commit
b58b6e828e
|
@ -2570,10 +2570,18 @@ exports[`better eslint`] = {
|
|||
"public/app/features/migrate-to-cloud/api/index.ts:5381": [
|
||||
[0, 0, 0, "Do not use export all (\`export * from ...\`)", "0"]
|
||||
],
|
||||
"public/app/features/migrate-to-cloud/onprem/ConfigureSnapshot.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
],
|
||||
"public/app/features/migrate-to-cloud/onprem/NameCell.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
|
||||
],
|
||||
"public/app/features/migrate-to-cloud/onprem/resourceDependency.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
],
|
||||
"public/app/features/migrate-to-cloud/onprem/useNotifyOnSuccess.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
onDisconnect: () => void;
|
||||
|
||||
showBuildSnapshot: boolean;
|
||||
buildSnapshotIsLoading: boolean;
|
||||
onBuildSnapshot: () => void;
|
||||
|
||||
showUploadSnapshot: boolean;
|
||||
uploadSnapshotIsLoading: boolean;
|
||||
onUploadSnapshot: () => void;
|
||||
|
||||
showRebuildSnapshot: boolean;
|
||||
onRebuildSnapshot: () => void;
|
||||
|
||||
onHighlightErrors: () => void;
|
||||
isHighlightErrors: boolean;
|
||||
|
@ -42,15 +39,12 @@ export function MigrationSummary(props: MigrationSummaryProps) {
|
|||
disconnectIsLoading,
|
||||
onDisconnect,
|
||||
|
||||
showBuildSnapshot,
|
||||
buildSnapshotIsLoading,
|
||||
onBuildSnapshot,
|
||||
|
||||
showUploadSnapshot,
|
||||
uploadSnapshotIsLoading,
|
||||
onUploadSnapshot,
|
||||
|
||||
showRebuildSnapshot,
|
||||
onRebuildSnapshot,
|
||||
|
||||
isHighlightErrors,
|
||||
onHighlightErrors,
|
||||
|
@ -125,21 +119,14 @@ export function MigrationSummary(props: MigrationSummaryProps) {
|
|||
</Stack>
|
||||
|
||||
<Stack gap={2} wrap justifyContent="flex-end">
|
||||
{showBuildSnapshot && (
|
||||
<Button disabled={isBusy} onClick={onBuildSnapshot} icon={buildSnapshotIsLoading ? 'spinner' : undefined}>
|
||||
<Trans i18nKey="migrate-to-cloud.summary.start-migration">Build snapshot</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showRebuildSnapshot && (
|
||||
<Button
|
||||
disabled={isBusy}
|
||||
onClick={onBuildSnapshot}
|
||||
icon={buildSnapshotIsLoading ? 'spinner' : undefined}
|
||||
disabled={isBusy || uploadSnapshotIsLoading}
|
||||
onClick={onRebuildSnapshot}
|
||||
variant="secondary"
|
||||
data-testid="migrate-to-cloud-summary-rebuild-snapshot-button"
|
||||
data-testid="migrate-to-cloud-summary-reconfigure-snapshot-button"
|
||||
>
|
||||
<Trans i18nKey="migrate-to-cloud.summary.rebuild-snapshot">Rebuild snapshot</Trans>
|
||||
<Trans i18nKey="migrate-to-cloud.summary.rebuild-snapshot">Reconfigure snapshot</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
useCancelSnapshotMutation,
|
||||
useCreateSnapshotMutation,
|
||||
useDeleteSessionMutation,
|
||||
useGetResourceDependenciesQuery,
|
||||
useGetSessionListQuery,
|
||||
useGetShapshotListQuery,
|
||||
useGetSnapshotQuery,
|
||||
|
@ -20,12 +21,13 @@ import {
|
|||
import { maybeAPIError } from '../api/errors';
|
||||
import { AlertWithTraceID } from '../shared/AlertWithTraceID';
|
||||
|
||||
import { DisconnectModal } from './DisconnectModal';
|
||||
import { ConfigureSnapshot } from './ConfigureSnapshot';
|
||||
import { EmptyState } from './EmptyState/EmptyState';
|
||||
import { MigrationSummary } from './MigrationSummary';
|
||||
import { ResourcesTable } from './ResourcesTable';
|
||||
import { BuildSnapshotCTA, CreatingSnapshotCTA } from './SnapshotCTAs';
|
||||
import { CreatingSnapshotCTA } from './SnapshotCTAs';
|
||||
import { SupportedTypesDisclosure } from './SupportedTypesDisclosure';
|
||||
import { ResourceTableItem } from './types';
|
||||
import { useNotifySuccessful } from './useNotifyOnSuccess';
|
||||
|
||||
/**
|
||||
|
@ -63,15 +65,6 @@ const SHOULD_POLL_STATUSES: Array<SnapshotDto['status']> = [
|
|||
'PROCESSING',
|
||||
];
|
||||
|
||||
const SNAPSHOT_REBUILD_STATUSES: Array<SnapshotDto['status']> = ['PENDING_UPLOAD', 'FINISHED', 'ERROR', 'UNKNOWN'];
|
||||
const SNAPSHOT_BUILDING_STATUSES: Array<SnapshotDto['status']> = ['INITIALIZING', 'CREATING'];
|
||||
const SNAPSHOT_UPLOADING_STATUSES: Array<SnapshotDto['status']> = ['UPLOADING', 'PENDING_PROCESSING', 'PROCESSING'];
|
||||
const SNAPSHOT_RESOURCES_HAVE_ERROR_STATUSES: Array<SnapshotDto['status']> = [
|
||||
'PROCESSING',
|
||||
'PENDING_PROCESSING',
|
||||
'FINISHED',
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function useGetLatestSnapshot(sessionUid?: string, page = 1, sortParams?: SortParams, showErrors = false) {
|
||||
|
@ -133,7 +126,6 @@ interface SortParams {
|
|||
}
|
||||
|
||||
export const Page = () => {
|
||||
const [disconnectModalOpen, setDisconnectModalOpen] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortParams, setSortParams] = useState<SortParams>({
|
||||
column: '',
|
||||
|
@ -141,6 +133,10 @@ export const Page = () => {
|
|||
});
|
||||
const [highlightErrors, setHighlightErrors] = useState(false);
|
||||
|
||||
const { data: resourceDependencies = { resourceDependencies: [] } } = useGetResourceDependenciesQuery();
|
||||
const [reconfiguring, setReconfiguring] = useState(false);
|
||||
const [lastSnapshotUid, setLastSnapshotUid] = useState<string | undefined>(undefined);
|
||||
|
||||
const [performCreateSnapshot, createSnapshotResult] = useCreateSnapshotMutation();
|
||||
const [performUploadSnapshot, uploadSnapshotResult] = useUploadSnapshotMutation();
|
||||
const [performCancelSnapshot, cancelSnapshotResult] = useCancelSnapshotMutation();
|
||||
|
@ -158,15 +154,18 @@ export const Page = () => {
|
|||
setPage(numPages);
|
||||
}
|
||||
}, [numPages, page]);
|
||||
|
||||
const [uiState, setUiState] = useState<'loading' | 'configure' | 'building' | 'built' | 'uploading' | 'uploaded'>(
|
||||
'loading'
|
||||
);
|
||||
|
||||
useNotifySuccessful(snapshot.data);
|
||||
|
||||
const sessionUid = session.data?.uid;
|
||||
const snapshotUid = snapshot.data?.uid;
|
||||
const isInitialLoading = session.isLoading;
|
||||
const status = snapshot.data?.status;
|
||||
const snapshotStatus = snapshot.data?.status;
|
||||
|
||||
// isBusy is not a loading state, but indicates that the system is doing *something*
|
||||
// and all buttons should be disabled
|
||||
// isBusy is not a loading state, but indicates that the system is doing *something* and all buttons should be disabled.
|
||||
const isBusy =
|
||||
createSnapshotResult.isLoading ||
|
||||
uploadSnapshotResult.isLoading ||
|
||||
|
@ -175,12 +174,135 @@ export const Page = () => {
|
|||
snapshot.isLoading ||
|
||||
disconnectResult.isLoading;
|
||||
|
||||
const showBuildSnapshot = !snapshot.isError && !snapshot.isLoading && !snapshot.data;
|
||||
const showBuildingSnapshot = SNAPSHOT_BUILDING_STATUSES.includes(status);
|
||||
const showUploadSnapshot =
|
||||
!snapshot.isError && (status === 'PENDING_UPLOAD' || SNAPSHOT_UPLOADING_STATUSES.includes(status));
|
||||
const showRebuildSnapshot = SNAPSHOT_REBUILD_STATUSES.includes(status);
|
||||
const showOnlyErrorsSwitch = SNAPSHOT_RESOURCES_HAVE_ERROR_STATUSES.includes(status);
|
||||
// Because we don't delete the previous snapshot if it exists, we need to keep track of the last snapshot.
|
||||
// When reconfiguring a snapshot, we need to pause the state machine until a new snapshot is created.
|
||||
// Reconfiguration is triggered by the user clicking the "Reconfigure snapshot" button at the end of the migration.
|
||||
useEffect(() => {
|
||||
if (
|
||||
reconfiguring &&
|
||||
lastSnapshotUid !== snapshot.data?.uid &&
|
||||
createSnapshotResult.isSuccess &&
|
||||
createSnapshotResult.data?.uid
|
||||
) {
|
||||
setLastSnapshotUid(createSnapshotResult.data.uid);
|
||||
setReconfiguring(false);
|
||||
}
|
||||
}, [
|
||||
createSnapshotResult.isSuccess,
|
||||
createSnapshotResult.data?.uid,
|
||||
lastSnapshotUid,
|
||||
snapshot.data?.uid,
|
||||
setLastSnapshotUid,
|
||||
setReconfiguring,
|
||||
reconfiguring,
|
||||
]);
|
||||
|
||||
// UI State Machine
|
||||
useEffect(() => {
|
||||
// If we don't have a session or the snapshot is still loading, don't do anything yet!
|
||||
if (!sessionUid || snapshot.isLoading || snapshot.isFetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
// When loading the page for the first time, we might already have a snapshot in a workable state.
|
||||
if (uiState === 'loading') {
|
||||
// Snapshot is being created.
|
||||
if (snapshotStatus === 'CREATING') {
|
||||
setUiState('building');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ready to upload.
|
||||
if (snapshotStatus === 'PENDING_UPLOAD') {
|
||||
setUiState('built');
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot is uploaded but still being processed by the backend.
|
||||
if (['UPLOADING', 'PENDING_PROCESSING', 'PROCESSING'].includes(snapshotStatus ?? '')) {
|
||||
setUiState('uploading');
|
||||
return;
|
||||
}
|
||||
|
||||
// Already uploaded with results, can reupload or reconfigure.
|
||||
if (snapshotStatus === 'FINISHED') {
|
||||
setUiState('uploaded');
|
||||
return;
|
||||
}
|
||||
|
||||
// Either the snapshot does not exist or is in an error state. In either case, we need to reconfigure.
|
||||
if (!snapshotStatus || snapshotStatus === 'ERROR') {
|
||||
setUiState('configure');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// When the snapshot is being created, go to the building (spinner) state.
|
||||
if (uiState === 'configure' && snapshotStatus === 'CREATING') {
|
||||
setUiState('building');
|
||||
return;
|
||||
}
|
||||
|
||||
// When the snapshot has finished building, go to the built state (ready to upload + resource table "not yet uploaded").
|
||||
// If we are reconfiguring, we pause the state machine until the new snapshot is actually set to PENDING_UPLOAD.
|
||||
// That in turn will cause `reconfiguring` to be set to `false` which will resume the state machine.
|
||||
if (!reconfiguring && uiState === 'building' && snapshotStatus === 'PENDING_UPLOAD') {
|
||||
setUiState('built');
|
||||
return;
|
||||
}
|
||||
|
||||
// When the snapshot is being uploaded, go to the uploading state (spinner + resource table "in progress").
|
||||
if (uiState === 'built' && (snapshotStatus === 'PROCESSING' || snapshotStatus === 'UPLOADING')) {
|
||||
setUiState('uploading');
|
||||
return;
|
||||
}
|
||||
|
||||
// When the snapshot has finished uploading, go to the uploaded state (resource table "success/error").
|
||||
if (uiState === 'uploading' && snapshotStatus === 'FINISHED') {
|
||||
setUiState('uploaded');
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: if there's nothing to choose in the snapshot, go back to reconfiguring.
|
||||
if (
|
||||
!reconfiguring &&
|
||||
(uiState === 'built' || uiState === 'uploaded') &&
|
||||
snapshotStatus !== 'FINISHED' &&
|
||||
(snapshot.data?.results?.length === 0 || snapshot.isUninitialized)
|
||||
) {
|
||||
setReconfiguring(true);
|
||||
setUiState('configure');
|
||||
return;
|
||||
}
|
||||
|
||||
// Error handling: if we are building a snapshot and there's an error, go back to the configure state.
|
||||
// Also display the error in the UI.
|
||||
if (uiState === 'building' && (createSnapshotResult.error || snapshot.isError)) {
|
||||
setUiState('configure');
|
||||
return;
|
||||
}
|
||||
|
||||
// Error handling: if we are uploading a snapshot and there's an error, force move to the uploaded state.
|
||||
// Also display the error in the UI, so the user can reconfigure it.
|
||||
if (uiState === 'uploading' && (uploadSnapshotResult.error || snapshotStatus === 'ERROR')) {
|
||||
setUiState('uploaded');
|
||||
return;
|
||||
}
|
||||
}, [
|
||||
sessionUid,
|
||||
snapshotStatus,
|
||||
snapshot.isLoading,
|
||||
snapshot.isFetching,
|
||||
snapshot.isUninitialized,
|
||||
snapshot.isError,
|
||||
setReconfiguring,
|
||||
setUiState,
|
||||
uiState,
|
||||
reconfiguring,
|
||||
snapshot.data?.results?.length,
|
||||
createSnapshotResult.error,
|
||||
uploadSnapshotResult.error,
|
||||
]);
|
||||
|
||||
const error = getError({
|
||||
snapshot: snapshot.data,
|
||||
|
@ -192,36 +314,22 @@ export const Page = () => {
|
|||
disconnectSnapshotError: disconnectResult.error,
|
||||
});
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
if (sessionUid) {
|
||||
performDisconnect({ uid: sessionUid });
|
||||
}
|
||||
}, [performDisconnect, sessionUid]);
|
||||
// Action Callbacks
|
||||
const handleCreateSnapshot = useCallback(
|
||||
(resourceTypes: Array<ResourceTableItem['type']>) => {
|
||||
if (sessionUid) {
|
||||
setUiState('building');
|
||||
|
||||
const handleCreateSnapshot = useCallback(() => {
|
||||
if (sessionUid) {
|
||||
performCreateSnapshot({
|
||||
uid: sessionUid,
|
||||
createSnapshotRequestDto: {
|
||||
// TODO: For the moment, pass all resource types. Once we have a frontend for selecting resource types,
|
||||
// we should pass the selected resource types instead.
|
||||
resourceTypes: [
|
||||
'DASHBOARD',
|
||||
'DATASOURCE',
|
||||
'FOLDER',
|
||||
'LIBRARY_ELEMENT',
|
||||
'ALERT_RULE',
|
||||
'ALERT_RULE_GROUP',
|
||||
'CONTACT_POINT',
|
||||
'NOTIFICATION_POLICY',
|
||||
'NOTIFICATION_TEMPLATE',
|
||||
'MUTE_TIMING',
|
||||
'PLUGIN',
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [performCreateSnapshot, sessionUid]);
|
||||
performCreateSnapshot({
|
||||
uid: sessionUid,
|
||||
createSnapshotRequestDto: {
|
||||
resourceTypes,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[performCreateSnapshot, sessionUid]
|
||||
);
|
||||
|
||||
const handleUploadSnapshot = useCallback(() => {
|
||||
if (sessionUid && snapshotUid) {
|
||||
|
@ -229,14 +337,31 @@ export const Page = () => {
|
|||
}
|
||||
}, [performUploadSnapshot, sessionUid, snapshotUid]);
|
||||
|
||||
const handleRebuildSnapshot = useCallback(() => {
|
||||
if (sessionUid && snapshotUid) {
|
||||
setReconfiguring(true);
|
||||
setUiState('configure');
|
||||
}
|
||||
}, [setUiState, setReconfiguring, sessionUid, snapshotUid]);
|
||||
|
||||
const handleCancelSnapshot = useCallback(() => {
|
||||
if (sessionUid && snapshotUid) {
|
||||
setUiState('configure');
|
||||
|
||||
performCancelSnapshot({ uid: sessionUid, snapshotUid: snapshotUid });
|
||||
}
|
||||
}, [performCancelSnapshot, sessionUid, snapshotUid]);
|
||||
}, [performCancelSnapshot, setUiState, sessionUid, snapshotUid]);
|
||||
|
||||
if (isInitialLoading) {
|
||||
// TODO: better loading state
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
if (sessionUid) {
|
||||
setUiState('loading');
|
||||
|
||||
performDisconnect({ uid: sessionUid });
|
||||
}
|
||||
}, [performDisconnect, setUiState, sessionUid]);
|
||||
|
||||
// Component Rendering
|
||||
if (session.isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<Trans i18nKey="migrate-to-cloud.summary.page-loading">Loading...</Trans>
|
||||
|
@ -249,82 +374,71 @@ export const Page = () => {
|
|||
return (
|
||||
<>
|
||||
<Stack direction="column" gap={2}>
|
||||
{session.data && (
|
||||
<MigrationSummary
|
||||
session={session.data}
|
||||
snapshot={snapshot.data}
|
||||
isBusy={isBusy}
|
||||
disconnectIsLoading={disconnectResult.isLoading}
|
||||
onDisconnect={handleDisconnect}
|
||||
showBuildSnapshot={showBuildSnapshot}
|
||||
buildSnapshotIsLoading={createSnapshotResult.isLoading}
|
||||
onBuildSnapshot={handleCreateSnapshot}
|
||||
showUploadSnapshot={showUploadSnapshot}
|
||||
uploadSnapshotIsLoading={uploadSnapshotResult.isLoading || SNAPSHOT_UPLOADING_STATUSES.includes(status)}
|
||||
onUploadSnapshot={handleUploadSnapshot}
|
||||
showRebuildSnapshot={showRebuildSnapshot}
|
||||
onHighlightErrors={() => setHighlightErrors(!highlightErrors)}
|
||||
isHighlightErrors={highlightErrors}
|
||||
showOnlyErrorsSwitch={showOnlyErrorsSwitch}
|
||||
/>
|
||||
)}
|
||||
<MigrationSummary
|
||||
session={session.data}
|
||||
snapshot={snapshot.data}
|
||||
isBusy={isBusy}
|
||||
disconnectIsLoading={disconnectResult.isLoading}
|
||||
onDisconnect={handleDisconnect}
|
||||
showUploadSnapshot={['built', 'uploading'].includes(uiState)}
|
||||
uploadSnapshotIsLoading={uploadSnapshotResult.isLoading || uiState === 'uploading'}
|
||||
onUploadSnapshot={handleUploadSnapshot}
|
||||
showRebuildSnapshot={['built', 'uploading', 'uploaded'].includes(uiState)}
|
||||
onRebuildSnapshot={handleRebuildSnapshot}
|
||||
onHighlightErrors={() => setHighlightErrors(!highlightErrors)}
|
||||
isHighlightErrors={highlightErrors}
|
||||
showOnlyErrorsSwitch={['uploading', 'uploaded'].includes(uiState)}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
{(['built', 'uploaded'].includes(uiState) || !!createSnapshotResult?.error) && error && (
|
||||
<AlertWithTraceID severity={error.severity} title={error.title} error={error.error}>
|
||||
<Text element="p">{error.body}</Text>
|
||||
</AlertWithTraceID>
|
||||
)}
|
||||
|
||||
{(showBuildSnapshot || showBuildingSnapshot) && (
|
||||
<Box display="flex" justifyContent="center" paddingY={10}>
|
||||
{showBuildSnapshot && (
|
||||
<BuildSnapshotCTA
|
||||
disabled={isBusy}
|
||||
isLoading={createSnapshotResult.isLoading}
|
||||
onClick={handleCreateSnapshot}
|
||||
/>
|
||||
)}
|
||||
{uiState === 'configure' && (
|
||||
<ConfigureSnapshot
|
||||
disabled={isBusy}
|
||||
isLoading={isBusy}
|
||||
onClick={handleCreateSnapshot}
|
||||
resourceDependencies={resourceDependencies.resourceDependencies || []}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showBuildingSnapshot && (
|
||||
<CreatingSnapshotCTA
|
||||
disabled={isBusy}
|
||||
isLoading={cancelSnapshotResult.isLoading}
|
||||
onClick={handleCancelSnapshot}
|
||||
/>
|
||||
)}
|
||||
{uiState === 'building' && (
|
||||
<Box display="flex" justifyContent="center" paddingY={10}>
|
||||
<CreatingSnapshotCTA
|
||||
disabled={isBusy}
|
||||
isLoading={cancelSnapshotResult.isLoading}
|
||||
onClick={handleCancelSnapshot}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{snapshot.data?.results && snapshot.data.results.length > 0 && (
|
||||
<Stack gap={4} direction="column">
|
||||
<ResourcesTable
|
||||
resources={snapshot.data.results}
|
||||
localPlugins={localPlugins}
|
||||
onChangePage={setPage}
|
||||
numberOfPages={numPages}
|
||||
page={page}
|
||||
onChangeSort={(a) => {
|
||||
const order = a.sortBy[0]?.desc === undefined ? undefined : a.sortBy[0]?.desc ? 'desc' : 'asc';
|
||||
if (sortParams.column !== a.sortBy[0]?.id || order !== sortParams.order) {
|
||||
setSortParams({
|
||||
column: a.sortBy[0]?.id,
|
||||
order: order,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SupportedTypesDisclosure />
|
||||
</Stack>
|
||||
)}
|
||||
{['built', 'uploading', 'uploaded'].includes(uiState) &&
|
||||
snapshot.data?.results &&
|
||||
snapshot.data?.results.length > 0 && (
|
||||
<Stack gap={4} direction="column">
|
||||
<ResourcesTable
|
||||
resources={snapshot.data.results}
|
||||
localPlugins={localPlugins}
|
||||
onChangePage={setPage}
|
||||
numberOfPages={numPages}
|
||||
page={page}
|
||||
onChangeSort={(a) => {
|
||||
const order = a.sortBy[0]?.desc === undefined ? undefined : a.sortBy[0]?.desc ? 'desc' : 'asc';
|
||||
if (sortParams.column !== a.sortBy[0]?.id || order !== sortParams.order) {
|
||||
setSortParams({
|
||||
column: a.sortBy[0]?.id,
|
||||
order: order,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SupportedTypesDisclosure />
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<DisconnectModal
|
||||
isOpen={disconnectModalOpen}
|
||||
isLoading={disconnectResult.isLoading}
|
||||
isError={disconnectResult.isError}
|
||||
onDisconnectConfirm={handleDisconnect}
|
||||
onDismiss={() => setDisconnectModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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",
|
||||
"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",
|
||||
|
|
Loading…
Reference in New Issue