Skip to content

Commit

Permalink
feat(instance) allow instance migration to a new project. fixes #1048
Browse files Browse the repository at this point in the history
Signed-off-by: David Edler <[email protected]>
  • Loading branch information
edlerd committed Jan 10, 2025
1 parent c4e86e0 commit 1843e60
Show file tree
Hide file tree
Showing 8 changed files with 271 additions and 64 deletions.
2 changes: 2 additions & 0 deletions src/api/instances.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export const migrateInstance = (
project: string,
target?: string,
pool?: string,
targetProject?: string,
): Promise<LxdOperationResponse> => {
let url = `/1.0/instances/${name}?project=${project}`;
if (target) {
Expand All @@ -105,6 +106,7 @@ export const migrateInstance = (
body: JSON.stringify({
migration: true,
pool,
project: targetProject,
}),
})
.then(handleResponse)
Expand Down
2 changes: 1 addition & 1 deletion src/pages/cluster/ClusterMemberSelectTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const ClusterMemberSelectTable: FC<Props> = ({ onSelect, disableMember }) => {

const rows = members.map((member) => {
const disableReason =
disableMember?.name === member.server_name && disableMember?.reason;
disableMember?.name === member.server_name ? disableMember?.reason : null;
const selectMember = () => {
if (disableReason) {
return;
Expand Down
65 changes: 65 additions & 0 deletions src/pages/instances/InstanceProjectMigration.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { FC } from "react";
import { ActionButton, Button } from "@canonical/react-components";
import { LxdInstance } from "types/instance";
import ProjectSelectTable from "pages/projects/ProjectSelectTable";

interface Props {
instance: LxdInstance;
onSelect: (pool: string) => void;
targetProject: string;
onCancel: () => void;
migrate: (pool: string) => void;
}

const InstanceProjectMigration: FC<Props> = ({
instance,
onSelect,
targetProject,
onCancel,
migrate,
}) => {
const summary = (
<div className="migrate-instance-summary">
<p>
This will migrate the instance <strong>{instance.name}</strong> to the
project <b>{targetProject}</b>.
</p>
</div>
);

return (
<>
{targetProject && summary}
{!targetProject && (
<ProjectSelectTable
onSelect={onSelect}
disableProject={{
name: instance.project,
reason: "Instance already in this project",
}}
/>
)}
<footer id="migrate-instance-actions" className="p-modal__footer">
<Button
className="u-no-margin--bottom"
type="button"
aria-label="cancel migrate"
appearance="base"
onClick={onCancel}
>
Cancel
</Button>
<ActionButton
appearance="positive"
className="u-no-margin--bottom"
onClick={() => migrate(targetProject)}
disabled={!targetProject}
>
Migrate
</ActionButton>
</footer>
</>
);
};

export default InstanceProjectMigration;
42 changes: 19 additions & 23 deletions src/pages/instances/InstanceStoragePoolMigration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ interface Props {
targetPool: string;
onCancel: () => void;
migrate: (pool: string) => void;
isClustered: boolean;
}

const InstanceStoragePoolMigration: FC<Props> = ({
Expand All @@ -19,7 +18,6 @@ const InstanceStoragePoolMigration: FC<Props> = ({
targetPool,
onCancel,
migrate,
isClustered,
}) => {
const summary = (
<div className="migrate-instance-summary">
Expand All @@ -42,27 +40,25 @@ const InstanceStoragePoolMigration: FC<Props> = ({
}}
/>
)}
{(isClustered || targetPool) && (
<footer id="migrate-instance-actions" className="p-modal__footer">
<Button
className="u-no-margin--bottom"
type="button"
aria-label="cancel migrate"
appearance="base"
onClick={onCancel}
>
Cancel
</Button>
<ActionButton
appearance="positive"
className="u-no-margin--bottom"
onClick={() => migrate(targetPool)}
disabled={!targetPool}
>
Migrate
</ActionButton>
</footer>
)}
<footer id="migrate-instance-actions" className="p-modal__footer">
<Button
className="u-no-margin--bottom"
type="button"
aria-label="cancel migrate"
appearance="base"
onClick={onCancel}
>
Cancel
</Button>
<ActionButton
appearance="positive"
className="u-no-margin--bottom"
onClick={() => migrate(targetPool)}
disabled={!targetPool}
>
Migrate
</ActionButton>
</footer>
</>
);
};
Expand Down
59 changes: 31 additions & 28 deletions src/pages/instances/MigrateInstanceModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import InstanceClusterMemberMigration from "./InstanceClusterMemberMigration";
import BackLink from "components/BackLink";
import InstanceStoragePoolMigration from "./InstanceStoragePoolMigration";
import { MigrationType, useInstanceMigration } from "util/instanceMigration";
import InstanceProjectMigration from "pages/instances/InstanceProjectMigration";

interface Props {
close: () => void;
Expand All @@ -17,9 +18,7 @@ interface Props {
const MigrateInstanceModal: FC<Props> = ({ close, instance }) => {
const { data: settings } = useSettings();
const isClustered = isClusteredServer(settings);
const [type, setType] = useState<MigrationType>(
isClustered ? "" : "root storage pool",
);
const [type, setType] = useState<MigrationType>("");
const [target, setTarget] = useState("");
const { handleMigrate } = useInstanceMigration({
onSuccess: close,
Expand All @@ -35,12 +34,6 @@ const MigrateInstanceModal: FC<Props> = ({ close, instance }) => {
};

const handleGoBack = () => {
// if lxd is not clustered, we close the modal
if (!isClustered) {
close();
return;
}

// if target is set, we are on the confirmation stage
if (target) {
setTarget("");
Expand All @@ -63,16 +56,11 @@ const MigrateInstanceModal: FC<Props> = ({ close, instance }) => {
const modalTitle = !type ? (
"Choose migration method"
) : (
<>
{isClustered && (
<BackLink
title={target ? "Confirm migration" : selectStepTitle}
onClick={handleGoBack}
linkText={target ? `Choose ${type}` : "Choose migration method"}
/>
)}
{!isClustered && (target ? "Confirm migration" : selectStepTitle)}
</>
<BackLink
title={target ? "Confirm migration" : selectStepTitle}
onClick={handleGoBack}
linkText={target ? `Choose ${type}` : "Choose migration method"}
/>
);

return (
Expand All @@ -82,18 +70,25 @@ const MigrateInstanceModal: FC<Props> = ({ close, instance }) => {
title={modalTitle}
onKeyDown={handleEscKey}
>
{isClustered && !type && (
{!type && (
<div className="choose-migration-type">
<FormLink
icon="cluster-host"
title="Migrate instance to a different cluster member"
onClick={() => setType("cluster member")}
/>
{isClustered && (
<FormLink
icon="cluster-host"
title="Migrate instance to a different cluster member"
onClick={() => setType("cluster member")}
/>
)}
<FormLink
icon="switcher-dashboard"
title="Migrate instance root storage to a different pool"
onClick={() => setType("root storage pool")}
/>
<FormLink
icon="folder"
title="Migrate instance to a different project"
onClick={() => setType("project")}
/>
</div>
)}

Expand All @@ -107,15 +102,23 @@ const MigrateInstanceModal: FC<Props> = ({ close, instance }) => {
/>
)}

{/* If lxd is not clustered, we always show storage pool migration table */}
{(type === "root storage pool" || !isClustered) && (
{type === "root storage pool" && (
<InstanceStoragePoolMigration
instance={instance}
onSelect={setTarget}
targetPool={target}
onCancel={handleGoBack}
migrate={handleMigrate}
isClustered={isClustered}
/>
)}

{type === "project" && (
<InstanceProjectMigration
instance={instance}
onSelect={setTarget}
targetProject={target}
onCancel={handleGoBack}
migrate={handleMigrate}
/>
)}
</Modal>
Expand Down
104 changes: 104 additions & 0 deletions src/pages/projects/ProjectSelectTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { FC } from "react";
import { Button, MainTable } from "@canonical/react-components";
import ScrollableTable from "components/ScrollableTable";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "util/queryKeys";
import classnames from "classnames";
import { fetchProjects } from "api/projects";

interface Props {
onSelect: (member: string) => void;
disableProject?: {
name: string;
reason: string;
};
}

const ProjectSelectTable: FC<Props> = ({ onSelect, disableProject }) => {
const { data: projects = [], isLoading } = useQuery({
queryKey: [queryKeys.projects],
queryFn: fetchProjects,
});

const headers = [
{ content: "Name", sortKey: "name" },
{ "aria-label": "Actions", className: "actions" },
];

const rows = projects.map((project) => {
const disableReason =
disableProject?.name === project.name ? disableProject?.reason : null;
const selectMember = () => {
if (disableReason) {
return;
}
onSelect(project.name);
};

return {
className: classnames("u-row", {
"u-text--muted": disableReason,
"u-row--disabled": disableReason,
}),
columns: [
{
content: (
<div
className="u-truncate migrate-instance-name"
title={project.name}
>
{project.name}
</div>
),
role: "cell",
"aria-label": "Name",
onClick: selectMember,
},
{
content: (
<Button
onClick={selectMember}
dense
title={disableReason}
disabled={Boolean(disableReason)}
>
Select
</Button>
),
role: "cell",
"aria-label": "Actions",
className: "u-align--right",
onClick: selectMember,
},
],
sortData: {
name: project.name.toLowerCase(),
},
};
});

return (
<div className="migrate-instance-table u-selectable-table-rows">
<ScrollableTable
dependencies={[projects]}
tableId="migrate-instance-table"
belowIds={["status-bar", "migrate-instance-actions"]}
>
<MainTable
id="migrate-instance-table"
headers={headers}
rows={rows}
sortable
className="u-table-layout--auto"
emptyStateMsg={
isLoading
? "Loading cluster members..."
: "No cluster members available"
}
/>
</ScrollableTable>
</div>
);
};

export default ProjectSelectTable;
2 changes: 1 addition & 1 deletion src/pages/storage/StoragePoolSelectTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const StoragePoolSelectTable: FC<Props> = ({ onSelect, disablePool }) => {

const rows = pools.map((pool) => {
const disableReason =
disablePool?.name === pool.name && disablePool?.reason;
disablePool?.name === pool.name ? disablePool?.reason : null;
const selectPool = () => {
if (disableReason) {
return;
Expand Down
Loading

0 comments on commit 1843e60

Please sign in to comment.