diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index d36c5a10fb..d06219f809 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -195,7 +195,7 @@ const Navigation: FC = () => { > {" "} Instances @@ -208,7 +208,7 @@ const Navigation: FC = () => { > {" "} Profiles @@ -222,7 +222,7 @@ const Navigation: FC = () => { > {" "} Networks @@ -231,7 +231,7 @@ const Navigation: FC = () => { toggleAccordionNav("storage")} open={openNavMenus.includes("storage")} @@ -290,7 +290,7 @@ const Navigation: FC = () => { > {" "} Images @@ -317,7 +317,7 @@ const Navigation: FC = () => { > {" "} Cluster @@ -471,10 +471,7 @@ const Navigation: FC = () => { rel="noopener noreferrer" title="Documentation" > - + Documentation @@ -503,7 +500,7 @@ const Navigation: FC = () => { > Report a bug diff --git a/src/components/ResourceIcon.tsx b/src/components/ResourceIcon.tsx index 1792c4ca47..5e575d6ef8 100644 --- a/src/components/ResourceIcon.tsx +++ b/src/components/ResourceIcon.tsx @@ -1,12 +1,16 @@ import { Icon } from "@canonical/react-components"; import { FC } from "react"; +export type InstanceIconType = "container" | "virtual-machine" | "instance"; + export type ResourceIconType = | "container" | "virtual-machine" + | "instance" | "snapshot" | "profile" | "project" + | "cluster-group" | "cluster-member" | "network" | "pool" @@ -16,14 +20,17 @@ export type ResourceIconType = | "oidc-identity" | "certificate" | "auth-group" + | "idp-group" | "device"; const resourceIcons: Record = { container: "pods", "virtual-machine": "pods", + instance: "pods", snapshot: "snapshot", profile: "repository", project: "folder", + "cluster-group": "cluster-host", "cluster-member": "single-host", network: "exposed", pool: "status-queued-small", @@ -33,6 +40,7 @@ const resourceIcons: Record = { "oidc-identity": "user", certificate: "certificate", "auth-group": "user-group", + "idp-group": "user-group", device: "units", }; diff --git a/src/pages/cluster/ClusterGroupForm.tsx b/src/pages/cluster/ClusterGroupForm.tsx index 736e98b40c..740e5b4e30 100644 --- a/src/pages/cluster/ClusterGroupForm.tsx +++ b/src/pages/cluster/ClusterGroupForm.tsx @@ -28,6 +28,7 @@ import NotificationRow from "components/NotificationRow"; import BaseLayout from "components/BaseLayout"; import AutoExpandingTextArea from "components/AutoExpandingTextArea"; import { useToastNotification } from "context/toastNotificationProvider"; +import ResourceLink from "components/ResourceLink"; export interface ClusterGroupFormValues { description: string; @@ -85,7 +86,17 @@ const ClusterGroupForm: FC = ({ group }) => { .then(() => { const verb = group ? "saved" : "created"; navigate(`/ui/cluster/group/${values.name}`); - toastNotify.success(`Cluster group ${values.name} ${verb}.`); + toastNotify.success( + <> + Cluster group{" "} + {" "} + {verb}. + , + ); }) .catch((e: Error) => { formik.setSubmitting(false); diff --git a/src/pages/cluster/ClusterList.tsx b/src/pages/cluster/ClusterList.tsx index 769412ddfd..298860a2f7 100644 --- a/src/pages/cluster/ClusterList.tsx +++ b/src/pages/cluster/ClusterList.tsx @@ -116,7 +116,7 @@ const ClusterList: FC = () => { filteredMembers.length < 1 && ( } + image={} title="Cluster group empty" >

Add cluster members to this group.

@@ -136,7 +136,7 @@ const ClusterList: FC = () => { {!isClustered && ( } + image={} title="This server is not clustered" >

diff --git a/src/pages/cluster/actions/DeleteClusterGroupBtn.tsx b/src/pages/cluster/actions/DeleteClusterGroupBtn.tsx index 157aaebbe4..4e1474786c 100644 --- a/src/pages/cluster/actions/DeleteClusterGroupBtn.tsx +++ b/src/pages/cluster/actions/DeleteClusterGroupBtn.tsx @@ -6,6 +6,7 @@ import { queryKeys } from "util/queryKeys"; import { useQueryClient } from "@tanstack/react-query"; import { ConfirmationButton, useNotify } from "@canonical/react-components"; import { useToastNotification } from "context/toastNotificationProvider"; +import ResourceLink from "components/ResourceLink"; interface Props { group: string; @@ -23,7 +24,17 @@ const DeleteClusterGroupBtn: FC = ({ group }) => { deleteClusterGroup(group) .then(() => { navigate(`/ui/cluster`); - toastNotify.success(`Cluster group ${group} deleted.`); + toastNotify.success( + <> + Cluster group{" "} + {" "} + deleted. + , + ); }) .catch((e) => { setLoading(false); diff --git a/src/pages/cluster/actions/EvacuateClusterMemberBtn.tsx b/src/pages/cluster/actions/EvacuateClusterMemberBtn.tsx index 71b3b8fbee..15b55cdcfd 100644 --- a/src/pages/cluster/actions/EvacuateClusterMemberBtn.tsx +++ b/src/pages/cluster/actions/EvacuateClusterMemberBtn.tsx @@ -6,6 +6,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { LxdClusterMember } from "types/cluster"; import { ConfirmationButton, useNotify } from "@canonical/react-components"; import { useToastNotification } from "context/toastNotificationProvider"; +import ResourceLink from "components/ResourceLink"; interface Props { member: LxdClusterMember; @@ -22,7 +23,15 @@ const EvacuateClusterMemberBtn: FC = ({ member }) => { postClusterMemberState(member, "evacuate") .then(() => { toastNotify.success( - `Cluster member ${member.server_name} evacuation started.`, + <> + Cluster member{" "} + {" "} + evacuation started. + , ); }) .catch((e) => notify.failure("Cluster member evacuation failed", e)) diff --git a/src/pages/cluster/actions/RestoreClusterMemberBtn.tsx b/src/pages/cluster/actions/RestoreClusterMemberBtn.tsx index 24b06c40ec..cc68010adc 100644 --- a/src/pages/cluster/actions/RestoreClusterMemberBtn.tsx +++ b/src/pages/cluster/actions/RestoreClusterMemberBtn.tsx @@ -6,6 +6,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { LxdClusterMember } from "types/cluster"; import { ConfirmationButton, useNotify } from "@canonical/react-components"; import { useToastNotification } from "context/toastNotificationProvider"; +import ResourceLink from "components/ResourceLink"; interface Props { member: LxdClusterMember; @@ -22,7 +23,15 @@ const RestoreClusterMemberBtn: FC = ({ member }) => { postClusterMemberState(member, "restore") .then(() => { toastNotify.success( - `Cluster member ${member.server_name} restore started.`, + <> + Cluster member{" "} + {" "} + restore started. + , ); }) .catch((e) => notify.failure("Cluster member restore failed", e)) diff --git a/src/pages/images/ImageList.tsx b/src/pages/images/ImageList.tsx index 8fa575694a..ac5e4d2ec0 100644 --- a/src/pages/images/ImageList.tsx +++ b/src/pages/images/ImageList.tsx @@ -241,7 +241,7 @@ const ImageList: FC = () => { {images.length === 0 && ( } + image={} title="No images found in this project" >

diff --git a/src/pages/images/actions/DeleteImageBtn.tsx b/src/pages/images/actions/DeleteImageBtn.tsx index e557b1ddee..a0e9162f57 100644 --- a/src/pages/images/actions/DeleteImageBtn.tsx +++ b/src/pages/images/actions/DeleteImageBtn.tsx @@ -6,6 +6,7 @@ import { queryKeys } from "util/queryKeys"; import { ConfirmationButton, Icon } from "@canonical/react-components"; import { useEventQueue } from "context/eventQueue"; import { useToastNotification } from "context/toastNotificationProvider"; +import ResourceLabel from "components/ResourceLabel"; interface Props { image: LxdImage; @@ -22,6 +23,7 @@ const DeleteImageBtn: FC = ({ image, project }) => { const handleDelete = () => { setLoading(true); + const imageLabel = ; void deleteImage(image, project) .then((operation) => eventQueue.set( @@ -33,18 +35,23 @@ const DeleteImageBtn: FC = ({ image, project }) => { void queryClient.invalidateQueries({ queryKey: [queryKeys.projects, project], }); - toastNotify.success(`Image ${description} deleted.`); + toastNotify.success(<>Image {imageLabel} deleted.); }, (msg) => toastNotify.failure( `Image ${description} deletion failed`, new Error(msg), + imageLabel, ), () => setLoading(false), ), ) .catch((e) => { - toastNotify.failure(`Image ${description} deletion failed`, e); + toastNotify.failure( + `Image ${description} deletion failed`, + e, + imageLabel, + ); setLoading(false); }); }; diff --git a/src/pages/images/actions/DownloadImageBtn.tsx b/src/pages/images/actions/DownloadImageBtn.tsx index e3cdd15693..cffec01cb0 100644 --- a/src/pages/images/actions/DownloadImageBtn.tsx +++ b/src/pages/images/actions/DownloadImageBtn.tsx @@ -2,6 +2,7 @@ import { FC, useState } from "react"; import { LxdImage } from "types/image"; import { ActionButton, Icon } from "@canonical/react-components"; import { useToastNotification } from "context/toastNotificationProvider"; +import ResourceLink from "components/ResourceLink"; interface Props { image: LxdImage; @@ -17,6 +18,13 @@ const DownloadImageBtn: FC = ({ image, project }) => { const handleExport = () => { setLoading(true); + const imageLink = ( + + ); try { const a = document.createElement("a"); @@ -26,10 +34,17 @@ const DownloadImageBtn: FC = ({ image, project }) => { window.URL.revokeObjectURL(url); toastNotify.success( - `Image ${description} download started. Please check your downloads folder.`, + <> + Image {imageLink} download started. Please check your downloads + folder. + , ); } catch (e) { - toastNotify.failure(`Image ${description} was unable to download.`, e); + toastNotify.failure( + `Image ${description} was unable to download.`, + e, + imageLink, + ); } finally { setLoading(false); } diff --git a/src/pages/images/actions/UploadCustomIsoBtn.tsx b/src/pages/images/actions/UploadCustomIsoBtn.tsx index 933d8e3868..076e7ff6ca 100644 --- a/src/pages/images/actions/UploadCustomIsoBtn.tsx +++ b/src/pages/images/actions/UploadCustomIsoBtn.tsx @@ -5,12 +5,14 @@ import usePortal from "react-useportal"; import { useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { useToastNotification } from "context/toastNotificationProvider"; +import ResourceLink from "components/ResourceLink"; interface Props { className?: string; + project: string; } -const UploadCustomIsoBtn: FC = ({ className }) => { +const UploadCustomIsoBtn: FC = ({ className, project }) => { const toastNotify = useToastNotification(); const { openPortal, closePortal, isOpen, Portal } = usePortal(); const queryClient = useQueryClient(); @@ -20,7 +22,13 @@ const UploadCustomIsoBtn: FC = ({ className }) => { const handleFinish = (name: string) => { toastNotify.success( <> - Image {name} uploaded successfully + Custom ISO{" "} + {" "} + uploaded successfully. , ); void queryClient.invalidateQueries({ queryKey: [queryKeys.isoVolumes] }); diff --git a/src/pages/instances/CreateInstance.tsx b/src/pages/instances/CreateInstance.tsx index 9cbedcc3fb..6b41138336 100644 --- a/src/pages/instances/CreateInstance.tsx +++ b/src/pages/instances/CreateInstance.tsx @@ -17,7 +17,7 @@ import { LxdImageType, RemoteImage } from "types/image"; import { isContainerOnlyImage, isVmOnlyImage, LOCAL_ISO } from "util/images"; import { dump as dumpYaml } from "js-yaml"; import { yamlToObject } from "util/yaml"; -import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; import { LxdInstance } from "types/instance"; import { Location } from "history"; import InstanceCreateDetailsForm, { @@ -89,6 +89,9 @@ import OtherDeviceForm from "components/forms/OtherDeviceForm"; import YamlSwitch from "components/forms/YamlSwitch"; import YamlNotification from "components/forms/YamlNotification"; import ProxyDeviceForm from "components/forms/ProxyDeviceForm"; +import ResourceLabel from "components/ResourceLabel"; +import InstanceLinkChip from "./InstanceLinkChip"; +import { InstanceIconType } from "components/ResourceIcon"; export type CreateInstanceFormValues = InstanceDetailsFormValues & FormDeviceValues & @@ -146,8 +149,16 @@ const CreateInstance: FC = () => { }); }; - const notifyCreationStarted = (instanceName: string) => { - toastNotify.info(<>Creation for instance {instanceName} started.); + const notifyCreationStarted = ( + instanceName: string, + instanceType: InstanceIconType, + ) => { + toastNotify.info( + <> + Creation for instance{" "} + started. + , + ); }; const notifyCreatedNowStarting = (instanceLink: ReactNode) => { @@ -198,11 +209,15 @@ const CreateInstance: FC = () => { clearCache(); }; - const notifyCreationAndStarting = (instanceName: string) => { + const notifyCreationAndStarting = ( + instanceName: string, + instanceType: InstanceIconType, + ) => { toastNotify.info( <> - Instance {instanceName} creation has begun. The instance will - automatically start upon completion. + Instance {" "} + creation has begun. The instance will automatically start upon + completion. , ); }; @@ -211,11 +226,12 @@ const CreateInstance: FC = () => { instanceName: string, shouldStart: boolean, isIsoImage: boolean, + instanceType: InstanceIconType, ) => { const instanceLink = ( - - {instanceName} - + ); // only send a second request to start the instance if the lxd version does not support the instance_create_start api extension @@ -289,15 +305,21 @@ const CreateInstance: FC = () => { } if (shouldStart && hasInstanceCreateStart) { - notifyCreationAndStarting(instanceName); + notifyCreationAndStarting(instanceName, values.instanceType); } else { - notifyCreationStarted(instanceName); + notifyCreationStarted(instanceName, values.instanceType); } const isIsoImage = values.image?.server === LOCAL_ISO; eventQueue.set( operation.metadata.id, - () => creationCompletedHandler(instanceName, shouldStart, isIsoImage), + () => + creationCompletedHandler( + instanceName, + shouldStart, + isIsoImage, + values.instanceType, + ), (msg) => notifyCreationFailed( new Error(msg), diff --git a/src/pages/instances/EditInstance.tsx b/src/pages/instances/EditInstance.tsx index b30b9a574a..c9945ae9f2 100644 --- a/src/pages/instances/EditInstance.tsx +++ b/src/pages/instances/EditInstance.tsx @@ -52,7 +52,6 @@ import { useEventQueue } from "context/eventQueue"; import { hasDiskError, hasNetworkError } from "util/instanceValidation"; import FormFooterLayout from "components/forms/FormFooterLayout"; import { useToastNotification } from "context/toastNotificationProvider"; -import InstanceLink from "pages/instances/InstanceLink"; import { useDocs } from "context/useDocs"; import MigrationForm, { MigrationFormValues, @@ -63,6 +62,7 @@ import YamlSwitch from "components/forms/YamlSwitch"; import YamlNotification from "components/forms/YamlNotification"; import ProxyDeviceForm from "components/forms/ProxyDeviceForm"; import FormSubmitBtn from "components/forms/FormSubmitBtn"; +import InstanceLinkChip from "./InstanceLinkChip"; export interface InstanceEditDetailsFormValues { name: string; @@ -123,7 +123,7 @@ const EditInstance: FC = ({ instance }) => { // ensure the etag is set (it is missing on the yaml) instancePayload.etag = instance.etag; - const instanceLink = ; + const instanceLink = ; void updateInstance(instancePayload, project) .then((operation) => { diff --git a/src/pages/instances/InstanceConsole.tsx b/src/pages/instances/InstanceConsole.tsx index 0e7840710b..98a246b229 100644 --- a/src/pages/instances/InstanceConsole.tsx +++ b/src/pages/instances/InstanceConsole.tsx @@ -112,7 +112,7 @@ const InstanceConsole: FC = ({ instance }) => { {isGraphic && !isRunning && ( } + image={} title="Instance stopped" >

Start the instance to access the graphic console.

diff --git a/src/pages/instances/InstanceDetailHeader.tsx b/src/pages/instances/InstanceDetailHeader.tsx index 30a9f458a2..2f5dd65611 100644 --- a/src/pages/instances/InstanceDetailHeader.tsx +++ b/src/pages/instances/InstanceDetailHeader.tsx @@ -9,13 +9,12 @@ import * as Yup from "yup"; import { useEventQueue } from "context/eventQueue"; import { useToastNotification } from "context/toastNotificationProvider"; import { - instanceLinkFromName, instanceLinkFromOperation, instanceNameValidation, } from "util/instances"; import { getInstanceName } from "util/operations"; -import InstanceLink from "pages/instances/InstanceLink"; import InstanceDetailActions from "./InstanceDetailActions"; +import InstanceLinkChip from "./InstanceLinkChip"; interface Props { name: string; @@ -55,10 +54,15 @@ const InstanceDetailHeader: FC = ({ } void renameInstance(name, values.name, project) .then((operation) => { - const instanceLink = instanceLinkFromName({ - instanceName: values.name, - project, - }); + const instanceLink = ( + + ); eventQueue.set( operation.metadata.id, () => { @@ -76,7 +80,11 @@ const InstanceDetailHeader: FC = ({ toastNotify.failure( "Renaming instance failed.", new Error(msg), - instanceLinkFromOperation({ operation, project }), + instanceLinkFromOperation({ + operation, + project, + instanceType: instance?.type || "instance", + }), ), () => formik.setSubmitting(false), ); @@ -86,7 +94,7 @@ const InstanceDetailHeader: FC = ({ toastNotify.failure( `Renaming instance failed.`, e, - instance ? : undefined, + instance && , ); }); }, diff --git a/src/pages/instances/InstanceLinkChip.tsx b/src/pages/instances/InstanceLinkChip.tsx new file mode 100644 index 0000000000..8543de244b --- /dev/null +++ b/src/pages/instances/InstanceLinkChip.tsx @@ -0,0 +1,23 @@ +import { FC } from "react"; +import { LxdInstance } from "types/instance"; +import ResourceLink from "components/ResourceLink"; +import { InstanceIconType } from "components/ResourceIcon"; + +interface Props { + instance: Partial> & { + name: string; + type: InstanceIconType; + }; +} + +const InstanceLinkChip: FC = ({ instance }) => { + return ( + + ); +}; + +export default InstanceLinkChip; diff --git a/src/pages/instances/InstanceList.tsx b/src/pages/instances/InstanceList.tsx index 2d7d64f0eb..b911be9493 100644 --- a/src/pages/instances/InstanceList.tsx +++ b/src/pages/instances/InstanceList.tsx @@ -627,7 +627,7 @@ const InstanceList: FC = () => { {!hasInstances && ( } + image={} title="No instances found" >

diff --git a/src/pages/instances/InstanceSnapshotLinkChip.tsx b/src/pages/instances/InstanceSnapshotLinkChip.tsx new file mode 100644 index 0000000000..c2bbfb55cc --- /dev/null +++ b/src/pages/instances/InstanceSnapshotLinkChip.tsx @@ -0,0 +1,21 @@ +import { FC } from "react"; +import ResourceLink from "components/ResourceLink"; +import { PartialWithRequired } from "types/partial"; +import { LxdInstance } from "types/instance"; + +interface Props { + name: string; + instance: PartialWithRequired; +} + +const InstanceSnapshotLinkChip: FC = ({ name, instance }) => { + return ( + + ); +}; + +export default InstanceSnapshotLinkChip; diff --git a/src/pages/instances/InstanceSnapshots.tsx b/src/pages/instances/InstanceSnapshots.tsx index 2ecc2b9d4d..99fdc50303 100644 --- a/src/pages/instances/InstanceSnapshots.tsx +++ b/src/pages/instances/InstanceSnapshots.tsx @@ -291,7 +291,7 @@ const InstanceSnapshots = (props: Props) => { ) : ( } + image={} title="No snapshots found" >

diff --git a/src/pages/instances/InstanceTerminal.tsx b/src/pages/instances/InstanceTerminal.tsx index acc4d7c90d..645c947e3d 100644 --- a/src/pages/instances/InstanceTerminal.tsx +++ b/src/pages/instances/InstanceTerminal.tsx @@ -219,7 +219,7 @@ const InstanceTerminal: FC = ({ instance }) => { {!isRunning && ( } + image={} title="Instance stopped" >

Start the instance to access the terminal.

diff --git a/src/pages/instances/MigrateInstanceModal.tsx b/src/pages/instances/MigrateInstanceModal.tsx index 74cc504b38..e8c042fd0e 100644 --- a/src/pages/instances/MigrateInstanceModal.tsx +++ b/src/pages/instances/MigrateInstanceModal.tsx @@ -85,12 +85,12 @@ const MigrateInstanceModal: FC = ({ close, instance }) => { {isClustered && !type && (
setType("cluster member")} /> setType("root storage pool")} /> diff --git a/src/pages/instances/actions/AttachIsoBtn.tsx b/src/pages/instances/actions/AttachIsoBtn.tsx index f9076aaa64..97afcc3912 100644 --- a/src/pages/instances/actions/AttachIsoBtn.tsx +++ b/src/pages/instances/actions/AttachIsoBtn.tsx @@ -14,6 +14,8 @@ import { remoteImageToIsoDevice } from "util/formDevices"; import { useEventQueue } from "context/eventQueue"; import { useToastNotification } from "context/toastNotificationProvider"; import { instanceLinkFromOperation } from "util/instances"; +import ResourceLink from "components/ResourceLink"; +import InstanceLinkChip from "../InstanceLinkChip"; interface Props { instance: LxdInstance; @@ -41,19 +43,21 @@ const AttachIsoBtn: FC = ({ instance }) => { instance, values, ) as LxdInstance; + const instanceLink = ; void updateInstance(instanceMinusIso, project ?? "") .then((operation) => { - const instanceLink = instanceLinkFromOperation({ - operation, - project, - }); eventQueue.set( operation.metadata.id, () => toastNotify.success( <> - ISO {attachedIso?.source ?? ""} detached from{" "} - {instanceLink} + ISO{" "} + {" "} + detached from {instanceLink} , ), (msg) => @@ -72,7 +76,7 @@ const AttachIsoBtn: FC = ({ instance }) => { }) .catch((e) => { setLoading(false); - toastNotify.failure("Detaching ISO failed.", e); + toastNotify.failure("Detaching ISO failed.", e, instanceLink); }); }; @@ -88,13 +92,20 @@ const AttachIsoBtn: FC = ({ instance }) => { const instanceLink = instanceLinkFromOperation({ operation, project, + instanceType: instance.type, }); eventQueue.set( operation.metadata.id, () => toastNotify.success( <> - ISO {image.aliases} attached to {instanceLink} + ISO{" "} + {" "} + attached to {instanceLink} , ), (msg) => diff --git a/src/pages/instances/actions/DeleteInstanceBtn.tsx b/src/pages/instances/actions/DeleteInstanceBtn.tsx index 27e4a43b04..f848cc02fc 100644 --- a/src/pages/instances/actions/DeleteInstanceBtn.tsx +++ b/src/pages/instances/actions/DeleteInstanceBtn.tsx @@ -10,8 +10,9 @@ import { useEventQueue } from "context/eventQueue"; import { queryKeys } from "util/queryKeys"; import { useQueryClient } from "@tanstack/react-query"; import { useToastNotification } from "context/toastNotificationProvider"; -import InstanceLink from "pages/instances/InstanceLink"; import { useInstanceLoading } from "context/instanceLoading"; +import ResourceLabel from "components/ResourceLabel"; +import InstanceLinkChip from "../InstanceLinkChip"; interface Props { instance: LxdInstance; @@ -29,6 +30,8 @@ const DeleteInstanceBtn: FC = ({ instance, classname, onClose }) => { const handleDelete = () => { setLoading(true); + const instanceLink = ; + void deleteInstance(instance) .then((operation) => { eventQueue.set( @@ -38,23 +41,29 @@ const DeleteInstanceBtn: FC = ({ instance, classname, onClose }) => { queryKey: [queryKeys.projects, instance.project], }); navigate(`/ui/project/${instance.project}/instances`); - toastNotify.success(`Instance ${instance.name} deleted.`); + toastNotify.success( + <> + Instance{" "} + {" "} + deleted. + , + ); }, (msg) => toastNotify.failure( "Instance deletion failed", new Error(msg), - , + instanceLink, ), () => setLoading(false), ); }) .catch((e) => { - toastNotify.failure( - "Instance deletion failed", - e, - , - ); + toastNotify.failure("Instance deletion failed", e, instanceLink); setLoading(false); }); }; diff --git a/src/pages/instances/actions/ExportInstanceBtn.tsx b/src/pages/instances/actions/ExportInstanceBtn.tsx index 458d648b2a..6cd0668022 100644 --- a/src/pages/instances/actions/ExportInstanceBtn.tsx +++ b/src/pages/instances/actions/ExportInstanceBtn.tsx @@ -5,7 +5,7 @@ import classNames from "classnames"; import { createInstanceBackup } from "api/instances"; import { useEventQueue } from "context/eventQueue"; import { useToastNotification } from "context/toastNotificationProvider"; -import { Link } from "react-router-dom"; +import InstanceLinkChip from "../InstanceLinkChip"; interface Props { instance: LxdInstance; @@ -17,11 +17,7 @@ const ExportInstanceBtn: FC = ({ instance, classname, onClose }) => { const eventQueue = useEventQueue(); const toastNotify = useToastNotification(); - const instanceLink = ( - - {instance.name} - - ); + const instanceLink = ; const startDownload = (backupName: string) => { const url = `/1.0/instances/${instance.name}/backups/${backupName}/export?project=${instance.project}`; @@ -72,11 +68,16 @@ const ExportInstanceBtn: FC = ({ instance, classname, onClose }) => { toastNotify.failure( `Could not download instance ${instance.name}`, new Error(msg), + instanceLink, ), ); }) .catch((e) => - toastNotify.failure(`Could not download instance ${instance.name}`, e), + toastNotify.failure( + `Could not download instance ${instance.name}`, + e, + instanceLink, + ), ) .finally(() => { onClose?.(); diff --git a/src/pages/instances/actions/FreezeInstanceBtn.tsx b/src/pages/instances/actions/FreezeInstanceBtn.tsx index 1b88e90432..979ba535f0 100644 --- a/src/pages/instances/actions/FreezeInstanceBtn.tsx +++ b/src/pages/instances/actions/FreezeInstanceBtn.tsx @@ -4,11 +4,11 @@ import { useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { freezeInstance } from "api/instances"; import { useInstanceLoading } from "context/instanceLoading"; -import InstanceLink from "pages/instances/InstanceLink"; -import ItemName from "components/ItemName"; import { ConfirmationButton, Icon } from "@canonical/react-components"; import { useEventQueue } from "context/eventQueue"; import { useToastNotification } from "context/toastNotificationProvider"; +import ItemName from "components/ItemName"; +import InstanceLinkChip from "../InstanceLinkChip"; interface Props { instance: LxdInstance; @@ -30,6 +30,8 @@ const FreezeInstanceBtn: FC = ({ instance }) => { instanceLoading.getType(instance) === "Freezing" || instance.status === "Freezing"; + const instanceLink = ; + const handleFreeze = () => { instanceLoading.setLoading(instance, "Freezing"); void freezeInstance(instance) @@ -37,18 +39,14 @@ const FreezeInstanceBtn: FC = ({ instance }) => { eventQueue.set( operation.metadata.id, () => { - toastNotify.success( - <> - Instance frozen. - , - ); + toastNotify.success(<>Instance {instanceLink} frozen.); clearCache(); }, (msg) => { toastNotify.failure( "Instance freeze failed", new Error(msg), - , + instanceLink, ); // Delay clearing the cache, because the instance is reported as FROZEN // when a freeze operation failed, only shortly after it goes back to RUNNING @@ -61,11 +59,7 @@ const FreezeInstanceBtn: FC = ({ instance }) => { ); }) .catch((e) => { - toastNotify.failure( - "Instance freeze failed", - e, - , - ); + toastNotify.failure("Instance freeze failed", e, instanceLink); instanceLoading.setFinish(instance); }); }; diff --git a/src/pages/instances/actions/RestartInstanceBtn.tsx b/src/pages/instances/actions/RestartInstanceBtn.tsx index 164ad07eff..ca7bb3d740 100644 --- a/src/pages/instances/actions/RestartInstanceBtn.tsx +++ b/src/pages/instances/actions/RestartInstanceBtn.tsx @@ -4,12 +4,12 @@ import { useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { restartInstance } from "api/instances"; import { useInstanceLoading } from "context/instanceLoading"; -import InstanceLink from "pages/instances/InstanceLink"; import ConfirmationForce from "components/ConfirmationForce"; -import ItemName from "components/ItemName"; import { ConfirmationButton, Icon } from "@canonical/react-components"; import { useEventQueue } from "context/eventQueue"; import { useToastNotification } from "context/toastNotificationProvider"; +import ItemName from "components/ItemName"; +import InstanceLinkChip from "../InstanceLinkChip"; interface Props { instance: LxdInstance; @@ -25,23 +25,20 @@ const RestartInstanceBtn: FC = ({ instance }) => { instanceLoading.getType(instance) === "Restarting" || instance.status === "Restarting"; + const instanceLink = ; + const handleRestart = () => { instanceLoading.setLoading(instance, "Restarting"); void restartInstance(instance, isForce) .then((operation) => { eventQueue.set( operation.metadata.id, - () => - toastNotify.success( - <> - Instance restarted. - , - ), + () => toastNotify.success(<>Instance {instanceLink} restarted.), (msg) => toastNotify.failure( "Instance restart failed", new Error(msg), - , + instanceLink, ), () => { instanceLoading.setFinish(instance); @@ -52,11 +49,7 @@ const RestartInstanceBtn: FC = ({ instance }) => { ); }) .catch((e) => { - toastNotify.failure( - "Instance restart failed", - e, - , - ); + toastNotify.failure("Instance restart failed", e, instanceLink); instanceLoading.setFinish(instance); }); }; diff --git a/src/pages/instances/actions/StopInstanceBtn.tsx b/src/pages/instances/actions/StopInstanceBtn.tsx index 587e98a2b3..06083654bb 100644 --- a/src/pages/instances/actions/StopInstanceBtn.tsx +++ b/src/pages/instances/actions/StopInstanceBtn.tsx @@ -4,12 +4,12 @@ import { useQueryClient } from "@tanstack/react-query"; import { stopInstance } from "api/instances"; import { queryKeys } from "util/queryKeys"; import { useInstanceLoading } from "context/instanceLoading"; -import InstanceLink from "pages/instances/InstanceLink"; import ConfirmationForce from "components/ConfirmationForce"; -import ItemName from "components/ItemName"; import { ConfirmationButton, Icon } from "@canonical/react-components"; import { useEventQueue } from "context/eventQueue"; import { useToastNotification } from "context/toastNotificationProvider"; +import ItemName from "components/ItemName"; +import InstanceLinkChip from "../InstanceLinkChip"; interface Props { instance: LxdInstance; @@ -32,6 +32,8 @@ const StopInstanceBtn: FC = ({ instance }) => { instanceLoading.getType(instance) === "Stopping" || instance.status === "Stopping"; + const instanceLink = ; + const handleStop = () => { instanceLoading.setLoading(instance, "Stopping"); void stopInstance(instance, isForce) @@ -39,18 +41,14 @@ const StopInstanceBtn: FC = ({ instance }) => { eventQueue.set( operation.metadata.id, () => { - toastNotify.success( - <> - Instance stopped. - , - ); + toastNotify.success(<>Instance {instanceLink} stopped.); clearCache(); }, (msg) => { toastNotify.failure( "Instance stop failed", new Error(msg), - , + instanceLink, ); // Delay clearing the cache, because the instance is reported as STOPPED // when a stop operation failed, only shortly after it goes back to RUNNING @@ -63,11 +61,7 @@ const StopInstanceBtn: FC = ({ instance }) => { ); }) .catch((e) => { - toastNotify.failure( - "Instance stop failed", - e, - , - ); + toastNotify.failure("Instance stop failed", e, instanceLink); instanceLoading.setFinish(instance); }); }; diff --git a/src/pages/instances/actions/snapshots/InstanceSnapshotActions.tsx b/src/pages/instances/actions/snapshots/InstanceSnapshotActions.tsx index aea05682f3..6b2e606db1 100644 --- a/src/pages/instances/actions/snapshots/InstanceSnapshotActions.tsx +++ b/src/pages/instances/actions/snapshots/InstanceSnapshotActions.tsx @@ -14,6 +14,8 @@ import { useEventQueue } from "context/eventQueue"; import InstanceEditSnapshotBtn from "./InstanceEditSnapshotBtn"; import CreateImageFromInstanceSnapshotBtn from "pages/instances/actions/snapshots/CreateImageFromInstanceSnapshotBtn"; import CreateInstanceFromSnapshotBtn from "./CreateInstanceFromSnapshotBtn"; +import ResourceLabel from "components/ResourceLabel"; +import InstanceSnapshotLinkChip from "pages/instances/InstanceSnapshotLinkChip"; interface Props { instance: LxdInstance; @@ -43,7 +45,9 @@ const InstanceSnapshotActions: FC = ({ () => onSuccess( <> - Snapshot deleted. + Snapshot{" "} + {" "} + deleted. , ), (msg) => onFailure("Snapshot deletion failed", new Error(msg)), @@ -70,7 +74,12 @@ const InstanceSnapshotActions: FC = ({ () => onSuccess( <> - Snapshot restored. + Snapshot{" "} + {" "} + restored. , ), (msg) => onFailure("Snapshot restore failed", new Error(msg)), diff --git a/src/pages/instances/forms/CreateImageFromInstanceForm.tsx b/src/pages/instances/forms/CreateImageFromInstanceForm.tsx index 04d2e3f7da..3c33e9b60b 100644 --- a/src/pages/instances/forms/CreateImageFromInstanceForm.tsx +++ b/src/pages/instances/forms/CreateImageFromInstanceForm.tsx @@ -15,6 +15,7 @@ import * as Yup from "yup"; import { Link } from "react-router-dom"; import { useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; +import InstanceLinkChip from "../InstanceLinkChip"; interface Props { instance: LxdInstance; @@ -25,6 +26,7 @@ const CreateImageFromInstanceForm: FC = ({ instance, close }) => { const eventQueue = useEventQueue(); const toastNotify = useToastNotification(); const queryClient = useQueryClient(); + const instanceLink = ; const notifySuccess = () => { const created = ( @@ -32,7 +34,7 @@ const CreateImageFromInstanceForm: FC = ({ instance, close }) => { ); toastNotify.success( <> - Image {created} from instance {instance.name}. + Image {created} from instance {instanceLink}. , ); }; @@ -71,11 +73,7 @@ const CreateImageFromInstanceForm: FC = ({ instance, close }) => { createImage(getInstanceToImageBody(instance, values.isPublic), instance) .then((operation) => { - toastNotify.info( - <> - Creation of image from instance {instance.name} started. - , - ); + toastNotify.info(<>Creation of image from {instanceLink} started.); close(); eventQueue.set( operation.metadata.id, @@ -100,6 +98,7 @@ const CreateImageFromInstanceForm: FC = ({ instance, close }) => { toastNotify.failure( `Image creation from instance "${instance.name}" failed.`, new Error(msg), + instanceLink, ); }, ); @@ -108,6 +107,7 @@ const CreateImageFromInstanceForm: FC = ({ instance, close }) => { toastNotify.failure( `Image creation from instance "${instance.name}" failed.`, e, + instanceLink, ); }); }, diff --git a/src/pages/instances/forms/CreateImageFromInstanceSnapshotForm.tsx b/src/pages/instances/forms/CreateImageFromInstanceSnapshotForm.tsx index 28d9df6850..94f51afe10 100644 --- a/src/pages/instances/forms/CreateImageFromInstanceSnapshotForm.tsx +++ b/src/pages/instances/forms/CreateImageFromInstanceSnapshotForm.tsx @@ -15,6 +15,7 @@ import * as Yup from "yup"; import { Link } from "react-router-dom"; import { queryKeys } from "util/queryKeys"; import { useQueryClient } from "@tanstack/react-query"; +import InstanceSnapshotLinkChip from "../InstanceSnapshotLinkChip"; interface Props { instance: LxdInstance; @@ -30,6 +31,9 @@ const CreateImageFromInstanceSnapshotForm: FC = ({ const eventQueue = useEventQueue(); const toastNotify = useToastNotification(); const queryClient = useQueryClient(); + const snapshotLink = ( + + ); const notifySuccess = () => { const created = ( @@ -37,7 +41,7 @@ const CreateImageFromInstanceSnapshotForm: FC = ({ ); toastNotify.success( <> - Image {created} from snapshot {snapshot.name}. + Image {created} from snapshot {snapshotLink}. , ); }; @@ -80,11 +84,7 @@ const CreateImageFromInstanceSnapshotForm: FC = ({ instance, ) .then((operation) => { - toastNotify.info( - <> - Creation of image from snapshot {snapshot.name} started. - , - ); + toastNotify.info(<>Creation of image from {snapshotLink} started.); close(); eventQueue.set( operation.metadata.id, @@ -109,6 +109,7 @@ const CreateImageFromInstanceSnapshotForm: FC = ({ toastNotify.failure( `Image creation from snapshot "${snapshot.name}" failed.`, new Error(msg), + snapshotLink, ); }, ); @@ -117,6 +118,7 @@ const CreateImageFromInstanceSnapshotForm: FC = ({ toastNotify.failure( `Image creation from snapshot "${snapshot.name}" failed.`, e, + snapshotLink, ); }); }, diff --git a/src/pages/instances/forms/CreateInstanceFromSnapshotForm.tsx b/src/pages/instances/forms/CreateInstanceFromSnapshotForm.tsx index a91a1070ce..47d8e8b5ae 100644 --- a/src/pages/instances/forms/CreateInstanceFromSnapshotForm.tsx +++ b/src/pages/instances/forms/CreateInstanceFromSnapshotForm.tsx @@ -17,13 +17,14 @@ import { useSettings } from "context/useSettings"; import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { fetchStoragePools } from "api/storage-pools"; -import { Link } from "react-router-dom"; import { instanceNameValidation, truncateInstanceName } from "util/instances"; import { fetchProjects } from "api/projects"; import { LxdDiskDevice } from "types/device"; -import InstanceLink from "pages/instances/InstanceLink"; import { useEventQueue } from "context/eventQueue"; import ClusterMemberSelector from "pages/cluster/ClusterMemberSelector"; +import ResourceLabel from "components/ResourceLabel"; +import InstanceLinkChip from "../InstanceLinkChip"; +import { InstanceIconType } from "components/ResourceIcon"; interface Props { instance: LxdInstance; @@ -104,9 +105,13 @@ const CreateInstanceFromSnapshotForm: FC = ({ queryFn: () => fetchInstances(instance.project), }); - const notifySuccess = (name: string, project: string) => { + const notifySuccess = ( + name: string, + project: string, + type: InstanceIconType, + ) => { const instanceLink = ( - {name} + ); const message = <>Created instance {instanceLink}.; @@ -167,7 +172,9 @@ const CreateInstanceFromSnapshotForm: FC = ({ instance.name, ).required(), }), + onSubmit: (values) => { + const instanceLink = ; createInstance( JSON.stringify(instanceFromSnapshotPayload(values, instance, snapshot)), values.targetProject, @@ -175,25 +182,34 @@ const CreateInstanceFromSnapshotForm: FC = ({ ) .then((operation) => { toastNotify.info( - `Instance creation started for ${values.instanceName}.`, + <> + Instance creation started for{" "} + + . + , ); eventQueue.set( operation.metadata.id, - () => notifySuccess(values.instanceName, values.targetProject), + () => + notifySuccess( + values.instanceName, + values.targetProject, + instance.type, + ), (msg) => toastNotify.failure( "Instance creation failed.", new Error(msg), - , + instanceLink, ), ); }) .catch((e) => { - toastNotify.failure( - "Instance creation failed.", - e, - , - ); + toastNotify.failure("Instance creation failed.", e, instanceLink); }) .finally(() => { close(); diff --git a/src/pages/instances/forms/CreateInstanceSnapshotForm.tsx b/src/pages/instances/forms/CreateInstanceSnapshotForm.tsx index 3026fd7638..e4b7aab767 100644 --- a/src/pages/instances/forms/CreateInstanceSnapshotForm.tsx +++ b/src/pages/instances/forms/CreateInstanceSnapshotForm.tsx @@ -19,9 +19,11 @@ import { SnapshotFormValues, getExpiresAt } from "util/snapshots"; import { UNDEFINED_DATE, stringToIsoTime } from "util/helpers"; import { createInstanceSnapshot } from "api/instance-snapshots"; import { queryKeys } from "util/queryKeys"; -import ItemName from "components/ItemName"; import { TOOLTIP_OVER_MODAL_ZINDEX } from "util/zIndex"; import { useToastNotification } from "context/toastNotificationProvider"; +import InstanceLinkChip from "../InstanceLinkChip"; +import { getInstanceSnapshotName } from "util/operations"; +import InstanceSnapshotLinkChip from "../InstanceSnapshotLinkChip"; interface Props { close: () => void; @@ -57,6 +59,7 @@ const CreateInstanceSnapshotForm: FC = ({ getExpiresAt(values.expirationDate, values.expirationTime), ) : UNDEFINED_DATE; + const instanceLink = ; void createInstanceSnapshot( instance, values.name, @@ -72,8 +75,12 @@ const CreateInstanceSnapshotForm: FC = ({ }); onSuccess( <> - Snapshot created for instance{" "} - {instance.name}. + Snapshot{" "} + {" "} + created for instance {instanceLink}. , ); resetForm(); @@ -83,6 +90,7 @@ const CreateInstanceSnapshotForm: FC = ({ toastNotify.failure( `Snapshot creation failed for instance ${instance.name}`, new Error(msg), + instanceLink, ); formik.setSubmitting(false); close(); @@ -90,7 +98,7 @@ const CreateInstanceSnapshotForm: FC = ({ ), ) .catch((error: Error) => { - notify.failure("Snapshot creation failed", error); + notify.failure("Snapshot creation failed", error, instanceLink); formik.setSubmitting(false); close(); }); diff --git a/src/pages/instances/forms/DuplicateInstanceForm.tsx b/src/pages/instances/forms/DuplicateInstanceForm.tsx index add7a49f8b..b0190d92f8 100644 --- a/src/pages/instances/forms/DuplicateInstanceForm.tsx +++ b/src/pages/instances/forms/DuplicateInstanceForm.tsx @@ -21,10 +21,12 @@ import { useNavigate } from "react-router-dom"; import { instanceNameValidation, truncateInstanceName } from "util/instances"; import { fetchProjects } from "api/projects"; import { LxdDiskDevice } from "types/device"; -import InstanceLink from "pages/instances/InstanceLink"; import { useEventQueue } from "context/eventQueue"; import ClusterMemberSelector from "pages/cluster/ClusterMemberSelector"; import { getUniqueResourceName } from "util/helpers"; +import ResourceLink from "components/ResourceLink"; +import InstanceLinkChip from "../InstanceLinkChip"; +import { InstanceIconType } from "components/ResourceIcon"; interface Props { instance: LxdInstance; @@ -64,20 +66,23 @@ const DuplicateInstanceForm: FC = ({ instance, close }) => { queryFn: () => fetchInstances(instance.project), }); - const notifySuccess = (instanceName: string, instanceProject: string) => { + const notifySuccess = ( + name: string, + project: string, + type: InstanceIconType, + ) => { + const instanceUrl = `/ui/project/${project}/instance/${name}`; const message = ( <> - Created instance {instanceName}. + Created instance{" "} + . ); const actions = [ { label: "Configure", - onClick: () => - navigate( - `/ui/project/${instanceProject}/instance/${instanceName}/configuration`, - ), + onClick: () => navigate(`${instanceUrl}/configuration`), }, ]; @@ -109,6 +114,7 @@ const DuplicateInstanceForm: FC = ({ instance, close }) => { ).required(), }), onSubmit: (values) => { + const instanceLink = ; createInstance( JSON.stringify({ description: instance.description, @@ -135,24 +141,27 @@ const DuplicateInstanceForm: FC = ({ instance, close }) => { values.targetClusterMember, ) .then((operation) => { - toastNotify.info(`Duplication of instance ${instance.name} started.`); + toastNotify.info( + <>Duplication of instance {instanceLink} started., + ); eventQueue.set( operation.metadata.id, - () => notifySuccess(values.instanceName, values.targetProject), + () => + notifySuccess( + values.instanceName, + values.targetProject, + instance.type, + ), (msg) => toastNotify.failure( "Instance duplication failed.", new Error(msg), - , + instanceLink, ), ); }) .catch((e) => { - toastNotify.failure( - "Instance duplication failed.", - e, - , - ); + toastNotify.failure("Instance duplication failed.", e, instanceLink); }) .finally(() => { close(); diff --git a/src/pages/instances/forms/EditInstanceSnapshotForm.tsx b/src/pages/instances/forms/EditInstanceSnapshotForm.tsx index 8149f6725f..6441cd6154 100644 --- a/src/pages/instances/forms/EditInstanceSnapshotForm.tsx +++ b/src/pages/instances/forms/EditInstanceSnapshotForm.tsx @@ -6,7 +6,6 @@ import { renameInstanceSnapshot, updateInstanceSnapshot, } from "api/instance-snapshots"; -import ItemName from "components/ItemName"; import { useEventQueue } from "context/eventQueue"; import { useFormik } from "formik"; import { @@ -18,6 +17,8 @@ import { getInstanceSnapshotSchema } from "util/instanceSnapshots"; import { queryKeys } from "util/queryKeys"; import { SnapshotFormValues, getExpiresAt } from "util/snapshots"; import { useToastNotification } from "context/toastNotificationProvider"; +import InstanceLinkChip from "../InstanceLinkChip"; +import InstanceSnapshotLinkChip from "../InstanceSnapshotLinkChip"; interface Props { instance: LxdInstance; @@ -43,8 +44,8 @@ const EditInstanceSnapshotForm: FC = ({ }); onSuccess( <> - Snapshot saved for instance{" "} - {instance.name}. + Snapshot {" "} + saved for instance . , ); close(); @@ -56,6 +57,7 @@ const EditInstanceSnapshotForm: FC = ({ name: newName, } as LxdInstanceSnapshot) : snapshot; + const instanceLink = ; void updateInstanceSnapshot(instance, targetSnapshot, expiresAt) .then((operation) => eventQueue.set( @@ -65,18 +67,26 @@ const EditInstanceSnapshotForm: FC = ({ toastNotify.failure( `Snapshot update failed for instance ${instance.name}`, new Error(msg), + instanceLink, ); formik.setSubmitting(false); }, ), ) .catch((e) => { - toastNotify.failure("Snapshot update failed", e); + toastNotify.failure( + `Snapshot update failed for instance ${instance.name}`, + e, + instanceLink, + ); formik.setSubmitting(false); }); }; const rename = (newName: string, expiresAt?: string) => { + const snapshotLink = ( + + ); void renameInstanceSnapshot(instance, snapshot, newName) .then((operation) => eventQueue.set( @@ -90,15 +100,20 @@ const EditInstanceSnapshotForm: FC = ({ }, (msg) => { toastNotify.failure( - `Snapshot rename failed for instance ${instance.name}`, + `Snapshot rename failed for ${snapshot.name}`, new Error(msg), + snapshotLink, ); formik.setSubmitting(false); }, ), ) .catch((e) => { - toastNotify.failure("Snapshot rename failed", e); + toastNotify.failure( + `Snapshot rename failed for ${snapshot.name}`, + e, + snapshotLink, + ); formik.setSubmitting(false); }); }; diff --git a/src/pages/instances/forms/InstanceCreateDetailsForm.tsx b/src/pages/instances/forms/InstanceCreateDetailsForm.tsx index 8801772092..5e3f008a53 100644 --- a/src/pages/instances/forms/InstanceCreateDetailsForm.tsx +++ b/src/pages/instances/forms/InstanceCreateDetailsForm.tsx @@ -25,12 +25,13 @@ import AutoExpandingTextArea from "components/AutoExpandingTextArea"; import ScrollableForm from "components/ScrollableForm"; import { useSupportedFeatures } from "context/useSupportedFeatures"; import UploadInstanceFileBtn from "../actions/UploadInstanceFileBtn"; +import { InstanceIconType } from "components/ResourceIcon"; export interface InstanceDetailsFormValues { name?: string; description?: string; image?: RemoteImage; - instanceType: string; + instanceType: InstanceIconType; profiles: string[]; target?: string; entityType: "instance"; diff --git a/src/pages/instances/forms/UploadExternalFormatFileForm.tsx b/src/pages/instances/forms/UploadExternalFormatFileForm.tsx index c5fc26883a..99665d0b7e 100644 --- a/src/pages/instances/forms/UploadExternalFormatFileForm.tsx +++ b/src/pages/instances/forms/UploadExternalFormatFileForm.tsx @@ -21,7 +21,6 @@ import { createInstance } from "api/instances"; import { useFormik } from "formik"; import * as Yup from "yup"; import { useSettings } from "context/useSettings"; -import InstanceLink from "../InstanceLink"; import { UploadExternalFormatFileFormValues, uploadExternalFormatFilePayload, @@ -35,6 +34,8 @@ import InstanceFileTypeSelector, { InstanceFileType, } from "./InstanceFileTypeSelector"; import ClusterMemberSelector from "pages/cluster/ClusterMemberSelector"; +import ResourceLink from "components/ResourceLink"; +import ResourceLabel from "components/ResourceLabel"; interface Props { close: () => void; @@ -80,7 +81,7 @@ const UploadExternalFormatFileForm: FC = ({ toastNotify.info( <> Upload completed. Now creating instance{" "} - {instanceName}. + . , ); navigate(`/ui/project/${project?.name}/instances`); @@ -100,23 +101,18 @@ const UploadExternalFormatFileForm: FC = ({ }; const handleSuccess = (instanceName: string) => { + const instanceUrl = `/ui/project/${project?.name}/instance/${instanceName}`; const message = ( <> Created instance{" "} - - . + . ); const actions = [ { label: "Configure", - onClick: () => - navigate( - `/ui/project/${project?.name}/instance/${instanceName}/configuration`, - ), + onClick: () => navigate(`${instanceUrl}/configuration`), }, ]; diff --git a/src/pages/instances/forms/UploadInstanceBackupFileForm.tsx b/src/pages/instances/forms/UploadInstanceBackupFileForm.tsx index 4cfdd588b3..ea20eb5777 100644 --- a/src/pages/instances/forms/UploadInstanceBackupFileForm.tsx +++ b/src/pages/instances/forms/UploadInstanceBackupFileForm.tsx @@ -25,6 +25,8 @@ import { useSupportedFeatures } from "context/useSupportedFeatures"; import InstanceFileTypeSelector, { InstanceFileType, } from "./InstanceFileTypeSelector"; +import ResourceLink from "components/ResourceLink"; +import ResourceLabel from "components/ResourceLabel"; export interface UploadInstanceBackupFileFormValues { instanceFile: File | null; @@ -60,19 +62,18 @@ const UploadInstanceBackupFileForm: FC = ({ const { hasInstanceImportConversion } = useSupportedFeatures(); const handleSuccess = (instanceName: string) => { + const instanceUrl = `/ui/project/${project?.name}/instance/${instanceName}`; const message = ( <> - Created instance {instanceName}. + Created instance{" "} + . ); const actions = [ { label: "Configure", - onClick: () => - navigate( - `/ui/project/${project?.name}/instance/${instanceName}/configuration`, - ), + onClick: () => navigate(`${instanceUrl}/configuration`), }, ]; @@ -107,7 +108,7 @@ const UploadInstanceBackupFileForm: FC = ({ toastNotify.info( <> Upload completed. Now creating instance{" "} - {values.name}. + . , ); diff --git a/src/pages/login/Login.tsx b/src/pages/login/Login.tsx index c283cd5965..b64ad73b05 100644 --- a/src/pages/login/Login.tsx +++ b/src/pages/login/Login.tsx @@ -23,7 +23,7 @@ const Login: FC = () => { return (
- +

Login

{hasOidc && ( <> diff --git a/src/pages/networks/CreateNetwork.tsx b/src/pages/networks/CreateNetwork.tsx index a965c93fc2..0c077a2d73 100644 --- a/src/pages/networks/CreateNetwork.tsx +++ b/src/pages/networks/CreateNetwork.tsx @@ -24,6 +24,7 @@ import { slugify } from "util/slugify"; import FormFooterLayout from "components/forms/FormFooterLayout"; import { useToastNotification } from "context/toastNotificationProvider"; import YamlSwitch from "components/forms/YamlSwitch"; +import ResourceLink from "components/ResourceLink"; const CreateNetwork: FC = () => { const navigate = useNavigate(); @@ -91,7 +92,17 @@ const CreateNetwork: FC = () => { queryKey: [queryKeys.projects, project, queryKeys.networks], }); navigate(`/ui/project/${project}/networks`); - toastNotify.success(`Network ${values.name} created.`); + toastNotify.success( + <> + Network{" "} + {" "} + created. + , + ); }) .catch((e) => { formik.setSubmitting(false); diff --git a/src/pages/networks/EditNetwork.tsx b/src/pages/networks/EditNetwork.tsx index a1a3fc823c..bcbe5e66e7 100644 --- a/src/pages/networks/EditNetwork.tsx +++ b/src/pages/networks/EditNetwork.tsx @@ -24,6 +24,7 @@ import FormFooterLayout from "components/forms/FormFooterLayout"; import { useToastNotification } from "context/toastNotificationProvider"; import YamlSwitch from "components/forms/YamlSwitch"; import FormSubmitBtn from "components/forms/FormSubmitBtn"; +import ResourceLink from "components/ResourceLink"; interface Props { network: LxdNetwork; @@ -89,7 +90,17 @@ const EditNetwork: FC = ({ network, project }) => { network.name, ], }); - toastNotify.success(`Network ${network.name} updated.`); + toastNotify.success( + <> + Network{""} + {" "} + updated. + , + ); }) .catch((e) => { notify.failure("Network update failed", e); diff --git a/src/pages/networks/NetworkDetailHeader.tsx b/src/pages/networks/NetworkDetailHeader.tsx index 28e8cbbbff..50b3c6d591 100644 --- a/src/pages/networks/NetworkDetailHeader.tsx +++ b/src/pages/networks/NetworkDetailHeader.tsx @@ -9,6 +9,7 @@ import { renameNetwork } from "api/networks"; import DeleteNetworkBtn from "pages/networks/actions/DeleteNetworkBtn"; import { useNotify } from "@canonical/react-components"; import { useToastNotification } from "context/toastNotificationProvider"; +import ResourceLink from "components/ResourceLink"; interface Props { name: string; @@ -48,8 +49,14 @@ const NetworkDetailHeader: FC = ({ name, network, project }) => { } renameNetwork(name, values.name, project) .then(() => { - navigate(`/ui/project/${project}/network/${values.name}`); - toastNotify.success(`Network ${name} renamed to ${values.name}.`); + const url = `/ui/project/${project}/network/${values.name}`; + navigate(url); + toastNotify.success( + <> + Network {name} renamed to{" "} + . + , + ); void formik.setFieldValue("isRenaming", false); }) .catch((e) => { diff --git a/src/pages/networks/NetworkForwards.tsx b/src/pages/networks/NetworkForwards.tsx index 2e92b8e69f..9fef4044e8 100644 --- a/src/pages/networks/NetworkForwards.tsx +++ b/src/pages/networks/NetworkForwards.tsx @@ -152,7 +152,7 @@ const NetworkForwards: FC = ({ network, project }) => { {!isLoading && !hasNetworkForwards && ( } + image={} title="No network forwards found" >

There are no network forwards in this project.

diff --git a/src/pages/networks/NetworkList.tsx b/src/pages/networks/NetworkList.tsx index a813408b28..faa93477e0 100644 --- a/src/pages/networks/NetworkList.tsx +++ b/src/pages/networks/NetworkList.tsx @@ -173,7 +173,7 @@ const NetworkList: FC = () => { {!isLoading && !hasNetworks && ( } + image={} title="No networks found" >

There are no networks in this project.

diff --git a/src/pages/networks/actions/DeleteNetworkBtn.tsx b/src/pages/networks/actions/DeleteNetworkBtn.tsx index 1519149e03..51b6a98a55 100644 --- a/src/pages/networks/actions/DeleteNetworkBtn.tsx +++ b/src/pages/networks/actions/DeleteNetworkBtn.tsx @@ -7,6 +7,7 @@ import { queryKeys } from "util/queryKeys"; import { useQueryClient } from "@tanstack/react-query"; import { ConfirmationButton, useNotify } from "@canonical/react-components"; import { useToastNotification } from "context/toastNotificationProvider"; +import ResourceLabel from "components/ResourceLabel"; interface Props { network: LxdNetwork; @@ -31,7 +32,12 @@ const DeleteNetworkBtn: FC = ({ network, project }) => { query.queryKey[2] === queryKeys.networks, }); navigate(`/ui/project/${project}/networks`); - toastNotify.success(`Network ${network.name} deleted.`); + toastNotify.success( + <> + Network {" "} + deleted. + , + ); }) .catch((e) => { setLoading(false); diff --git a/src/pages/permissions/actions/DeleteGroupModal.tsx b/src/pages/permissions/actions/DeleteGroupModal.tsx index 9764516b67..aac2fa728c 100644 --- a/src/pages/permissions/actions/DeleteGroupModal.tsx +++ b/src/pages/permissions/actions/DeleteGroupModal.tsx @@ -6,6 +6,7 @@ import { } from "@canonical/react-components"; import { useQueryClient } from "@tanstack/react-query"; import { deleteGroup, deleteGroups } from "api/auth-groups"; +import ResourceLabel from "components/ResourceLabel"; import { useToastNotification } from "context/toastNotificationProvider"; import { ChangeEvent, FC, useState } from "react"; import { LxdGroup } from "types/permissions"; @@ -44,9 +45,14 @@ const DeleteGroupModal: FC = ({ groups, close }) => { ? deleteGroup(groups[0].name) : deleteGroups(groups.map((group) => group.name)); - const successMessage = hasSingleGroup - ? `Group ${groups[0].name} deleted.` - : `${groups.length} groups deleted.`; + const successMessage = hasSingleGroup ? ( + <> + Group {" "} + deleted. + + ) : ( + `${groups.length} groups deleted.` + ); mutationPromise .then(() => { diff --git a/src/pages/permissions/actions/DeleteIdpGroupsModal.tsx b/src/pages/permissions/actions/DeleteIdpGroupsModal.tsx index 5150b4adc5..b067ef051e 100644 --- a/src/pages/permissions/actions/DeleteIdpGroupsModal.tsx +++ b/src/pages/permissions/actions/DeleteIdpGroupsModal.tsx @@ -1,6 +1,7 @@ import { ConfirmationModal, useNotify } from "@canonical/react-components"; import { useQueryClient } from "@tanstack/react-query"; import { deleteIdpGroup, deleteIdpGroups } from "api/auth-idp-groups"; +import ResourceLabel from "components/ResourceLabel"; import { useToastNotification } from "context/toastNotificationProvider"; import { FC, useState } from "react"; import { IdpGroup } from "types/permissions"; @@ -25,9 +26,15 @@ const DeleteIdpGroupsModal: FC = ({ idpGroups, close }) => { ? deleteIdpGroup(idpGroups[0].name) : deleteIdpGroups(idpGroups.map((group) => group.name)); - const successMessage = hasOneGroup - ? `IDP group ${idpGroups[0].name} deleted.` - : `${idpGroups.length} IDP groups deleted.`; + const successMessage = hasOneGroup ? ( + <> + IDP group{" "} + {" "} + deleted. + + ) : ( + `${idpGroups.length} IDP groups deleted.` + ); mutationPromise .then(() => { diff --git a/src/pages/permissions/panels/CreateGroupPanel.tsx b/src/pages/permissions/panels/CreateGroupPanel.tsx index 00d8c9af77..83d08d10ce 100644 --- a/src/pages/permissions/panels/CreateGroupPanel.tsx +++ b/src/pages/permissions/panels/CreateGroupPanel.tsx @@ -21,6 +21,7 @@ import EditGroupPermissionsForm, { FormPermission, } from "pages/permissions/panels/EditGroupPermissionsForm"; import GroupHeaderTitle from "pages/permissions/panels/GroupHeaderTitle"; +import ResourceLink from "components/ResourceLink"; export type GroupSubForm = "identity" | "permission" | null; @@ -46,7 +47,17 @@ const CreateGroupPanel: FC = () => { }); const handleSuccess = (groupName: string) => { - toastNotify.success(`Group ${groupName} created.`); + toastNotify.success( + <> + Group{" "} + {" "} + created. + , + ); closePanel(); }; diff --git a/src/pages/permissions/panels/CreateIdpGroupPanel.tsx b/src/pages/permissions/panels/CreateIdpGroupPanel.tsx index 4d1d38ed35..df0bebad6a 100644 --- a/src/pages/permissions/panels/CreateIdpGroupPanel.tsx +++ b/src/pages/permissions/panels/CreateIdpGroupPanel.tsx @@ -15,6 +15,7 @@ import IdpGroupForm, { IdpGroupFormValues } from "../forms/IdpGroupForm"; import GroupSelection from "./GroupSelection"; import useEditHistory from "util/useEditHistory"; import GroupSelectionActions from "../actions/GroupSelectionActions"; +import ResourceLink from "components/ResourceLink"; type GroupEditHistory = { groupsAdded: Set; @@ -76,7 +77,17 @@ const CreateIdpGroupPanel: FC = () => { formik.setSubmitting(true); createIdpGroup(newGroup) .then(() => { - toastNotify.success(`IDP group ${values.name} created.`); + toastNotify.success( + <> + IDP group{" "} + {" "} + created. + , + ); void queryClient.invalidateQueries({ queryKey: [queryKeys.idpGroups], }); diff --git a/src/pages/permissions/panels/EditGroupPanel.tsx b/src/pages/permissions/panels/EditGroupPanel.tsx index 3025b4694f..15d6aeb46e 100644 --- a/src/pages/permissions/panels/EditGroupPanel.tsx +++ b/src/pages/permissions/panels/EditGroupPanel.tsx @@ -39,6 +39,7 @@ import { pluralize } from "util/instanceBulkActions"; import GroupHeaderTitle from "pages/permissions/panels/GroupHeaderTitle"; import { GroupSubForm } from "pages/permissions/panels/CreateGroupPanel"; import { fetchImageList } from "api/images"; +import ResourceLink from "components/ResourceLink"; interface Props { group: LxdGroup; @@ -177,7 +178,17 @@ const EditGroupPanel: FC = ({ group, onClose }) => { mutationPromise .then(() => { closePanel(); - toastNotify.success(`Group ${values.name} updated.`); + toastNotify.success( + <> + Group{" "} + {" "} + updated. + , + ); }) .catch((e) => { notify.failure("Group update failed", e); diff --git a/src/pages/permissions/panels/EditIdpGroupPanel.tsx b/src/pages/permissions/panels/EditIdpGroupPanel.tsx index 069f6cc2bd..5bacfe10cf 100644 --- a/src/pages/permissions/panels/EditIdpGroupPanel.tsx +++ b/src/pages/permissions/panels/EditIdpGroupPanel.tsx @@ -16,6 +16,7 @@ import useEditHistory from "util/useEditHistory"; import IdpGroupForm, { IdpGroupFormValues } from "../forms/IdpGroupForm"; import GroupSelection from "./GroupSelection"; import GroupSelectionActions from "../actions/GroupSelectionActions"; +import ResourceLink from "components/ResourceLink"; type GroupEditHistory = { groupsAdded: Set; @@ -152,7 +153,17 @@ const EditIdpGroupPanel: FC = ({ idpGroup, onClose }) => { formik.setSubmitting(true); mutationPromise .then(() => { - toastNotify.success(`IDP group ${values.name} updated.`); + toastNotify.success( + <> + IDP group{" "} + {" "} + updated. + , + ); void queryClient.invalidateQueries({ queryKey: [queryKeys.idpGroups], }); diff --git a/src/pages/permissions/panels/GroupIdentitiesPanelConfirmModal.tsx b/src/pages/permissions/panels/GroupIdentitiesPanelConfirmModal.tsx index a2c3608b17..59a07bdbf1 100644 --- a/src/pages/permissions/panels/GroupIdentitiesPanelConfirmModal.tsx +++ b/src/pages/permissions/panels/GroupIdentitiesPanelConfirmModal.tsx @@ -13,6 +13,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useToastNotification } from "context/toastNotificationProvider"; import { updateIdentities } from "api/auth-identities"; import { queryKeys } from "util/queryKeys"; +import ResourceLink from "components/ResourceLink"; interface Props { onConfirm: () => void; @@ -81,9 +82,18 @@ const GroupIdentitiesPanelConfirmModal: FC = ({ const modifiedGroupNames = Object.keys(groupIdentitiesChangeSummary); const successMessage = - modifiedGroupNames.length > 1 - ? `Updated identities for ${modifiedGroupNames.length} groups` - : `Updated identities for ${modifiedGroupNames[0]}`; + modifiedGroupNames.length > 1 ? ( + `Updated identities for ${modifiedGroupNames.length} groups` + ) : ( + <> + Updated identities for{" "} + + + ); toastNotify.success(successMessage); panelParams.clear(); diff --git a/src/pages/permissions/panels/IdentityGroupsPanelConfirmModal.tsx b/src/pages/permissions/panels/IdentityGroupsPanelConfirmModal.tsx index 6a7c2c3828..f6ea3565f8 100644 --- a/src/pages/permissions/panels/IdentityGroupsPanelConfirmModal.tsx +++ b/src/pages/permissions/panels/IdentityGroupsPanelConfirmModal.tsx @@ -12,6 +12,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { useToastNotification } from "context/toastNotificationProvider"; import usePanelParams from "util/usePanelParams"; +import ResourceLink from "components/ResourceLink"; interface Props { onConfirm: () => void; @@ -71,9 +72,18 @@ const IdentityGroupsPanelConfirmModal: FC = ({ const modifiedGroupNames = Object.keys(identityGroupsChangeSummary); const successMessage = - modifiedGroupNames.length > 1 - ? `Updated groups for ${modifiedGroupNames.length} identities` - : `Updated groups for ${modifiedGroupNames[0]}`; + modifiedGroupNames.length > 1 ? ( + `Updated groups for ${modifiedGroupNames.length} identities` + ) : ( + <> + Updated groups for{" "} + + + ); toastNotify.success(successMessage); panelParams.clear(); diff --git a/src/pages/profiles/CreateProfile.tsx b/src/pages/profiles/CreateProfile.tsx index ffb1e5ef72..0116df63a4 100644 --- a/src/pages/profiles/CreateProfile.tsx +++ b/src/pages/profiles/CreateProfile.tsx @@ -71,6 +71,7 @@ import YamlSwitch from "components/forms/YamlSwitch"; import YamlNotification from "components/forms/YamlNotification"; import ProxyDeviceForm from "components/forms/ProxyDeviceForm"; import { PROXY_DEVICES } from "pages/instances/forms/InstanceFormMenu"; +import ResourceLink from "components/ResourceLink"; export type CreateProfileFormValues = ProfileDetailsFormValues & FormDeviceValues & @@ -125,7 +126,17 @@ const CreateProfile: FC = () => { createProfile(JSON.stringify(profilePayload), project) .then(() => { navigate(`/ui/project/${project}/profiles`); - toastNotify.success(`Profile ${values.name} created.`); + toastNotify.success( + <> + Profile{" "} + {" "} + created. + , + ); }) .catch((e: Error) => { formik.setSubmitting(false); diff --git a/src/pages/profiles/EditProfile.tsx b/src/pages/profiles/EditProfile.tsx index 5b96b27c9e..b8851e9b99 100644 --- a/src/pages/profiles/EditProfile.tsx +++ b/src/pages/profiles/EditProfile.tsx @@ -67,6 +67,7 @@ import YamlNotification from "components/forms/YamlNotification"; import { PROXY_DEVICES } from "pages/instances/forms/InstanceFormMenu"; import ProxyDeviceForm from "components/forms/ProxyDeviceForm"; import FormSubmitBtn from "components/forms/FormSubmitBtn"; +import ResourceLink from "components/ResourceLink"; export type EditProfileFormValues = ProfileDetailsFormValues & FormDeviceValues & @@ -124,7 +125,17 @@ const EditProfile: FC = ({ profile, featuresProfiles }) => { updateProfile(profilePayload, project) .then(() => { - toastNotify.success(`Profile ${profile.name} updated.`); + toastNotify.success( + <> + Profile{" "} + {" "} + updated. + , + ); void formik.setValues(getProfileEditValues(profilePayload)); }) .catch((e: Error) => { diff --git a/src/pages/profiles/ProfileDetailHeader.tsx b/src/pages/profiles/ProfileDetailHeader.tsx index b64d72620d..b47d5efa8d 100644 --- a/src/pages/profiles/ProfileDetailHeader.tsx +++ b/src/pages/profiles/ProfileDetailHeader.tsx @@ -9,6 +9,7 @@ import * as Yup from "yup"; import { checkDuplicateName } from "util/helpers"; import { useNotify } from "@canonical/react-components"; import { useToastNotification } from "context/toastNotificationProvider"; +import ResourceLink from "components/ResourceLink"; interface Props { name: string; @@ -55,7 +56,17 @@ const ProfileDetailHeader: FC = ({ renameProfile(name, values.name, project) .then(() => { navigate(`/ui/project/${project}/profile/${values.name}`); - toastNotify.success(`Profile ${name} renamed to ${values.name}.`); + toastNotify.success( + <> + Profile {name} renamed to{" "} + + . + , + ); void formik.setFieldValue("isRenaming", false); }) .catch((e) => { diff --git a/src/pages/profiles/actions/DeleteProfileBtn.tsx b/src/pages/profiles/actions/DeleteProfileBtn.tsx index 2e589b4df5..2fc429cacb 100644 --- a/src/pages/profiles/actions/DeleteProfileBtn.tsx +++ b/src/pages/profiles/actions/DeleteProfileBtn.tsx @@ -13,6 +13,7 @@ import classnames from "classnames"; import { useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { useToastNotification } from "context/toastNotificationProvider"; +import ResourceLabel from "components/ResourceLabel"; interface Props { profile: LxdProfile; @@ -40,7 +41,12 @@ const DeleteProfileBtn: FC = ({ queryKey: [queryKeys.projects, project], }); navigate(`/ui/project/${project}/profiles`); - toastNotify.success(`Profile ${profile.name} deleted.`); + toastNotify.success( + <> + Profile {" "} + deleted. + , + ); }) .catch((e) => { setLoading(false); diff --git a/src/pages/projects/CreateProject.tsx b/src/pages/projects/CreateProject.tsx index cbc39d6053..8f1140f081 100644 --- a/src/pages/projects/CreateProject.tsx +++ b/src/pages/projects/CreateProject.tsx @@ -41,6 +41,7 @@ import FormFooterLayout from "components/forms/FormFooterLayout"; import { slugify } from "util/slugify"; import { useToastNotification } from "context/toastNotificationProvider"; import { useSupportedFeatures } from "context/useSupportedFeatures"; +import ResourceLink from "components/ResourceLink"; export type ProjectFormValues = ProjectDetailsFormValues & ProjectResourceLimitsFormValues & @@ -111,7 +112,17 @@ const CreateProject: FC = () => { ) .then(() => { navigate(`/ui/project/${values.name}/instances`); - toastNotify.success(`Project ${values.name} created.`); + toastNotify.success( + <> + Project{" "} + {" "} + created. + , + ); }) .catch((e: Error) => { formik.setSubmitting(false); diff --git a/src/pages/projects/EditProject.tsx b/src/pages/projects/EditProject.tsx index 418117a779..db57e3d200 100644 --- a/src/pages/projects/EditProject.tsx +++ b/src/pages/projects/EditProject.tsx @@ -22,6 +22,7 @@ import { slugify } from "util/slugify"; import { useToastNotification } from "context/toastNotificationProvider"; import { useSupportedFeatures } from "context/useSupportedFeatures"; import FormSubmitBtn from "components/forms/FormSubmitBtn"; +import ResourceLink from "components/ResourceLink"; interface Props { project: LxdProject; @@ -68,7 +69,17 @@ const EditProject: FC = ({ project }) => { updateProject(projectPayload) .then(() => { - toastNotify.success(`Project ${project.name} updated.`); + toastNotify.success( + <> + Project{" "} + {" "} + updated. + , + ); void formik.setFieldValue("readOnly", true); }) .catch((e: Error) => { diff --git a/src/pages/projects/ProjectConfigurationHeader.tsx b/src/pages/projects/ProjectConfigurationHeader.tsx index 8e68fcb431..2483b23f43 100644 --- a/src/pages/projects/ProjectConfigurationHeader.tsx +++ b/src/pages/projects/ProjectConfigurationHeader.tsx @@ -11,6 +11,7 @@ import HelpLink from "components/HelpLink"; import { useEventQueue } from "context/eventQueue"; import { useDocs } from "context/useDocs"; import { useToastNotification } from "context/toastNotificationProvider"; +import ResourceLink from "components/ResourceLink"; interface Props { project: LxdProject; @@ -47,14 +48,25 @@ const ProjectConfigurationHeader: FC = ({ project }) => { formik.setSubmitting(false); return; } + const oldProjectLink = ( + + ); void renameProject(project.name, values.name) .then((operation) => eventQueue.set( operation.metadata.id, () => { - navigate(`/ui/project/${values.name}/configuration`); + const url = `/ui/project/${values.name}/configuration`; + navigate(url); toastNotify.success( - `Project ${project.name} renamed to ${values.name}.`, + <> + Project {project.name} renamed to{" "} + . + , ); void formik.setFieldValue("isRenaming", false); }, @@ -62,13 +74,18 @@ const ProjectConfigurationHeader: FC = ({ project }) => { toastNotify.failure( `Renaming project ${project.name} failed`, new Error(msg), + oldProjectLink, ), () => formik.setSubmitting(false), ), ) .catch((e) => { formik.setSubmitting(false); - toastNotify.failure(`Renaming project ${project.name} failed`, e); + toastNotify.failure( + `Renaming project ${project.name} failed`, + e, + oldProjectLink, + ); }); }, }); diff --git a/src/pages/projects/actions/DeleteProjectBtn.tsx b/src/pages/projects/actions/DeleteProjectBtn.tsx index 55cf3194dd..777a198d38 100644 --- a/src/pages/projects/actions/DeleteProjectBtn.tsx +++ b/src/pages/projects/actions/DeleteProjectBtn.tsx @@ -17,6 +17,7 @@ import classnames from "classnames"; import { useToastNotification } from "context/toastNotificationProvider"; import { filterUsedByType } from "util/usedBy"; import { ResourceType } from "util/resourceDetails"; +import ResourceLabel from "components/ResourceLabel"; interface Props { project: LxdProject; @@ -99,7 +100,12 @@ const DeleteProjectBtn: FC = ({ project }) => { deleteProject(project) .then(() => { navigate(`/ui/project/default/instances`); - toastNotify.success(`Project ${project.name} deleted.`); + toastNotify.success( + <> + Project {" "} + deleted. + , + ); }) .catch((e) => { setLoading(false); diff --git a/src/pages/storage/CreateStoragePool.tsx b/src/pages/storage/CreateStoragePool.tsx index af2c31e55b..34f203586a 100644 --- a/src/pages/storage/CreateStoragePool.tsx +++ b/src/pages/storage/CreateStoragePool.tsx @@ -25,6 +25,7 @@ import { useToastNotification } from "context/toastNotificationProvider"; import { yamlToObject } from "util/yaml"; import { LxdStoragePool } from "types/storage"; import YamlSwitch from "components/forms/YamlSwitch"; +import ResourceLink from "components/ResourceLink"; const CreateStoragePool: FC = () => { const navigate = useNavigate(); @@ -74,7 +75,17 @@ const CreateStoragePool: FC = () => { queryKey: [queryKeys.storage], }); navigate(`/ui/project/${project}/storage/pools`); - toastNotify.success(`Storage pool ${storagePool.name} created.`); + toastNotify.success( + <> + Storage pool{" "} + {" "} + created. + , + ); }) .catch((e) => { formik.setSubmitting(false); diff --git a/src/pages/storage/CustomIsoList.tsx b/src/pages/storage/CustomIsoList.tsx index 3cf33d8116..8f76106981 100644 --- a/src/pages/storage/CustomIsoList.tsx +++ b/src/pages/storage/CustomIsoList.tsx @@ -26,6 +26,7 @@ import CustomLayout from "components/CustomLayout"; import PageHeader from "components/PageHeader"; import HelpLink from "components/HelpLink"; import NotificationRow from "components/NotificationRow"; +import ResourceLabel from "components/ResourceLabel"; const CustomIsoList: FC = () => { const docBaseLink = useDocs(); @@ -79,7 +80,13 @@ const CustomIsoList: FC = () => { volume={image.volume} project={project} onFinish={() => - toastNotify.success(`Custom iso ${image.aliases} deleted.`) + toastNotify.success( + <> + Custom iso{" "} + {" "} + deleted. + , + ) } />, ]} @@ -153,7 +160,7 @@ const CustomIsoList: FC = () => { const content = !hasImages ? ( } + image={} title="No custom ISOs found in this project" >

Custom ISOs will appear here

@@ -167,7 +174,7 @@ const CustomIsoList: FC = () => {

- +
) : (
@@ -230,7 +237,10 @@ const CustomIsoList: FC = () => { {hasImages && ( - + )} diff --git a/src/pages/storage/EditStoragePool.tsx b/src/pages/storage/EditStoragePool.tsx index 47331e3cb3..f28ca14935 100644 --- a/src/pages/storage/EditStoragePool.tsx +++ b/src/pages/storage/EditStoragePool.tsx @@ -30,6 +30,7 @@ import { useSettings } from "context/useSettings"; import { getSupportedStorageDrivers } from "util/storageOptions"; import YamlSwitch from "components/forms/YamlSwitch"; import FormSubmitBtn from "components/forms/FormSubmitBtn"; +import ResourceLink from "components/ResourceLink"; interface Props { pool: LxdStoragePool; @@ -81,7 +82,17 @@ const EditStoragePool: FC = ({ pool }) => { mutation() .then(async () => { - toastNotify.success(`Storage pool ${savedPool.name} updated.`); + toastNotify.success( + <> + Storage pool{" "} + {" "} + updated. + , + ); const member = clusterMembers[0]?.server_name ?? undefined; const updatedPool = await fetchStoragePool(values.name, member); void formik.setValues(toStoragePoolFormValues(updatedPool)); diff --git a/src/pages/storage/MigrateVolumeBtn.tsx b/src/pages/storage/MigrateVolumeBtn.tsx index 25aeed2d59..8f0ad137fc 100644 --- a/src/pages/storage/MigrateVolumeBtn.tsx +++ b/src/pages/storage/MigrateVolumeBtn.tsx @@ -4,12 +4,13 @@ import usePortal from "react-useportal"; import { useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { useEventQueue } from "context/eventQueue"; -import ItemName from "components/ItemName"; import { useToastNotification } from "context/toastNotificationProvider"; import { LxdStorageVolume } from "types/storage"; import MigrateVolumeModal from "./MigrateVolumeModal"; import { migrateStorageVolume } from "api/storage-pools"; import { useNavigate } from "react-router-dom"; +import ResourceLabel from "components/ResourceLabel"; +import ResourceLink from "components/ResourceLink"; interface Props { storageVolume: LxdStorageVolume; @@ -35,16 +36,29 @@ const MigrateVolumeBtn: FC = ({ newTarget: string, storageVolume: LxdStorageVolume, ) => { + const oldVolumeUrl = `/ui/project/${storageVolume.project}/storage/pool/${storageVolume.pool}/volumes/${storageVolume.type}/${storageVolume.name}`; + const newVolumeUrl = `/ui/project/${storageVolume.project}/storage/pool/${newTarget}/volumes/${storageVolume.type}/${storageVolume.name}`; + + const volume = ( + + ); + const pool = ( + + ); toastNotify.success( <> - Volume {" "} - successfully migrated to pool{" "} - + Volume {volume} successfully migrated to pool {pool} , ); - const oldVolumeUrl = `/ui/project/${storageVolume.project}/storage/pool/${storageVolume.pool}/volumes/${storageVolume.type}/${storageVolume.name}`; - const newVolumeUrl = `/ui/project/${storageVolume.project}/storage/pool/${newTarget}/volumes/${storageVolume.type}/${storageVolume.name}`; if (window.location.pathname.startsWith(oldVolumeUrl)) { navigate(newVolumeUrl); } @@ -52,13 +66,18 @@ const MigrateVolumeBtn: FC = ({ const notifyFailure = ( e: unknown, - storageVolume: string, + volumeName: string, targetPool: string, ) => { setVolumeLoading(false); toastNotify.failure( - `Migration failed for volume ${storageVolume} to pool ${targetPool}`, + `Migration failed for volume ${volumeName} to pool ${targetPool}`, e, + , ); }; @@ -87,8 +106,20 @@ const MigrateVolumeBtn: FC = ({ (err) => handleFailure(err, storageVolume.name, targetPool), handleFinish, ); + const volume = ( + + ); + const pool = ( + + ); toastNotify.info( - `Migration started for volume ${storageVolume.name} to pool ${targetPool}`, + <> + Migration started for volume {volume} to pool {pool} + , ); void queryClient.invalidateQueries({ queryKey: [queryKeys.storage, storageVolume.name, project], diff --git a/src/pages/storage/StoragePoolHeader.tsx b/src/pages/storage/StoragePoolHeader.tsx index fdb7e8b8dc..af1857f804 100644 --- a/src/pages/storage/StoragePoolHeader.tsx +++ b/src/pages/storage/StoragePoolHeader.tsx @@ -9,6 +9,7 @@ import DeleteStoragePoolBtn from "pages/storage/actions/DeleteStoragePoolBtn"; import { testDuplicateStoragePoolName } from "util/storagePool"; import { useNotify } from "@canonical/react-components"; import { useToastNotification } from "context/toastNotificationProvider"; +import ResourceLink from "components/ResourceLink"; interface Props { name: string; @@ -42,9 +43,13 @@ const StoragePoolHeader: FC = ({ name, pool, project }) => { } renameStoragePool(name, values.name) .then(() => { - navigate(`/ui/project/${project}/storage/pool/${values.name}`); + const url = `/ui/project/${project}/storage/pool/${values.name}`; + navigate(url); toastNotify.success( - `Storage pool ${name} renamed to ${values.name}.`, + <> + Storage pool {name} renamed to{" "} + . + , ); void formik.setFieldValue("isRenaming", false); }) diff --git a/src/pages/storage/StoragePools.tsx b/src/pages/storage/StoragePools.tsx index a5c6973e82..5482950b89 100644 --- a/src/pages/storage/StoragePools.tsx +++ b/src/pages/storage/StoragePools.tsx @@ -181,7 +181,7 @@ const StoragePools: FC = () => { ) : ( } + image={} title="No pools found in this project" >

Storage pools will appear here.

diff --git a/src/pages/storage/StorageVolumeHeader.tsx b/src/pages/storage/StorageVolumeHeader.tsx index 872a36df61..18abb48613 100644 --- a/src/pages/storage/StorageVolumeHeader.tsx +++ b/src/pages/storage/StorageVolumeHeader.tsx @@ -12,6 +12,8 @@ import { useToastNotification } from "context/toastNotificationProvider"; import MigrateVolumeBtn from "./MigrateVolumeBtn"; import DuplicateVolumeBtn from "./actions/DuplicateVolumeBtn"; import { useSupportedFeatures } from "context/useSupportedFeatures"; +import ResourceLink from "components/ResourceLink"; +import ResourceLabel from "components/ResourceLabel"; interface Props { volume: LxdStorageVolume; @@ -53,11 +55,13 @@ const StorageVolumeHeader: FC = ({ volume, project }) => { } renameStorageVolume(project, volume, values.name) .then(() => { - navigate( - `/ui/project/${project}/storage/pool/${volume.pool}/volumes/${volume.type}/${values.name}`, - ); + const url = `/ui/project/${project}/storage/pool/${volume.pool}/volumes/${volume.type}/${values.name}`; + navigate(url); toastNotify.success( - `Storage volume ${volume.name} renamed to ${values.name}.`, + <> + Storage volume {volume.name} renamed to{" "} + . + , ); void formik.setFieldValue("isRenaming", false); }) @@ -99,7 +103,13 @@ const StorageVolumeHeader: FC = ({ volume, project }) => { hasIcon={true} onFinish={() => { navigate(`/ui/project/${project}/storage/volumes`); - toastNotify.success(`Storage volume ${volume.name} deleted.`); + toastNotify.success( + <> + Storage volume{" "} + {" "} + deleted. + , + ); }} classname={classname} /> diff --git a/src/pages/storage/StorageVolumeSnapshots.tsx b/src/pages/storage/StorageVolumeSnapshots.tsx index 52da78e61d..6c64f9ea13 100644 --- a/src/pages/storage/StorageVolumeSnapshots.tsx +++ b/src/pages/storage/StorageVolumeSnapshots.tsx @@ -289,7 +289,7 @@ const StorageVolumeSnapshots: FC = ({ volume }) => { ) : ( } + image={} title="No snapshots found" >

diff --git a/src/pages/storage/StorageVolumes.tsx b/src/pages/storage/StorageVolumes.tsx index 33bf5d2e7a..7898e2a3d2 100644 --- a/src/pages/storage/StorageVolumes.tsx +++ b/src/pages/storage/StorageVolumes.tsx @@ -368,7 +368,7 @@ const StorageVolumes: FC = () => { const content = !hasVolumes ? ( } + image={} title="No volumes found in this project" >

Storage volumes will appear here

diff --git a/src/pages/storage/UploadCustomIso.tsx b/src/pages/storage/UploadCustomIso.tsx index a7a04aa52d..bad7bfd5d2 100644 --- a/src/pages/storage/UploadCustomIso.tsx +++ b/src/pages/storage/UploadCustomIso.tsx @@ -70,7 +70,8 @@ const UploadCustomIso: FC = ({ onCancel, onFinish }) => { eventQueue.set( operation.metadata.id, () => onFinish(name, pool), - (msg) => toastNotify.failure("Image import failed", new Error(msg)), + (msg) => + toastNotify.failure("Custom ISO upload failed", new Error(msg)), () => { setLoading(false); setUploadState(null); @@ -87,7 +88,7 @@ const UploadCustomIso: FC = ({ onCancel, onFinish }) => { ) .catch((e: AxiosError>) => { const error = new Error(e.response?.data.error); - toastNotify.failure("Image import failed", error); + toastNotify.failure("Custom ISO upload failed", error); setLoading(false); setUploadState(null); }); diff --git a/src/pages/storage/VolumeSnapshotLinkChip.tsx b/src/pages/storage/VolumeSnapshotLinkChip.tsx new file mode 100644 index 0000000000..2acd03a0db --- /dev/null +++ b/src/pages/storage/VolumeSnapshotLinkChip.tsx @@ -0,0 +1,21 @@ +import { FC } from "react"; +import ResourceLink from "components/ResourceLink"; +import { PartialWithRequired } from "types/partial"; +import { LxdStorageVolume } from "types/storage"; + +interface Props { + name: string; + volume: PartialWithRequired; +} + +const VolumeSnapshotLinkChip: FC = ({ name, volume }) => { + return ( + + ); +}; + +export default VolumeSnapshotLinkChip; diff --git a/src/pages/storage/actions/CustomStorageVolumeActions.tsx b/src/pages/storage/actions/CustomStorageVolumeActions.tsx index 992489d83f..0d16292a98 100644 --- a/src/pages/storage/actions/CustomStorageVolumeActions.tsx +++ b/src/pages/storage/actions/CustomStorageVolumeActions.tsx @@ -7,6 +7,7 @@ import VolumeAddSnapshotBtn from "./snapshots/VolumeAddSnapshotBtn"; import { useToastNotification } from "context/toastNotificationProvider"; import { isSnapshotsDisabled } from "util/snapshots"; import { useProject } from "context/project"; +import ResourceLabel from "components/ResourceLabel"; interface Props { volume: LxdStorageVolume; @@ -33,7 +34,13 @@ const CustomStorageVolumeActions: FC = ({ volume, className }) => { volume={volume} project={project?.name ?? ""} onFinish={() => { - toastNotify.success(`Storage volume ${volume.name} deleted.`); + toastNotify.success( + <> + Storage volume{" "} + {" "} + deleted. + , + ); }} />, ]} diff --git a/src/pages/storage/actions/DeleteStoragePoolBtn.tsx b/src/pages/storage/actions/DeleteStoragePoolBtn.tsx index dab5eb4e58..6552715928 100644 --- a/src/pages/storage/actions/DeleteStoragePoolBtn.tsx +++ b/src/pages/storage/actions/DeleteStoragePoolBtn.tsx @@ -13,6 +13,7 @@ import { useNavigate } from "react-router-dom"; import { LxdStoragePool } from "types/storage"; import { queryKeys } from "util/queryKeys"; import { useToastNotification } from "context/toastNotificationProvider"; +import ResourceLabel from "components/ResourceLabel"; interface Props { pool: LxdStoragePool; @@ -40,7 +41,12 @@ const DeleteStoragePoolBtn: FC = ({ queryKey: [queryKeys.storage], }); navigate(`/ui/project/${project}/storage/pools`); - toastNotify.success(`Storage pool ${pool.name} deleted.`); + toastNotify.success( + <> + Storage pool {" "} + deleted. + , + ); }) .catch((e) => { setLoading(false); diff --git a/src/pages/storage/actions/snapshots/VolumeConfigureSnapshotModal.tsx b/src/pages/storage/actions/snapshots/VolumeConfigureSnapshotModal.tsx index e14e69901b..75a906b515 100644 --- a/src/pages/storage/actions/snapshots/VolumeConfigureSnapshotModal.tsx +++ b/src/pages/storage/actions/snapshots/VolumeConfigureSnapshotModal.tsx @@ -17,6 +17,7 @@ import { getStorageVolumeEditValues } from "util/storageVolumeEdit"; import { updateStorageVolume } from "api/storage-pools"; import StorageVolumeFormSnapshots from "pages/storage/forms/StorageVolumeFormSnapshots"; import { useToastNotification } from "context/toastNotificationProvider"; +import ResourceLink from "components/ResourceLink"; interface Props { volume: LxdStorageVolume; @@ -38,7 +39,15 @@ const VolumeConfigureSnapshotModal: FC = ({ volume, close }) => { }) .then(() => { toastNotify.success( - `Snapshot configuration updated for volume ${volume.name}.`, + <> + Snapshot configuration updated for volume{" "} + + . + , ); void queryClient.invalidateQueries({ queryKey: [queryKeys.storage], diff --git a/src/pages/storage/actions/snapshots/VolumeSnapshotActions.tsx b/src/pages/storage/actions/snapshots/VolumeSnapshotActions.tsx index bd84f7941b..fe7125ed96 100644 --- a/src/pages/storage/actions/snapshots/VolumeSnapshotActions.tsx +++ b/src/pages/storage/actions/snapshots/VolumeSnapshotActions.tsx @@ -17,6 +17,8 @@ import ItemName from "components/ItemName"; import { useEventQueue } from "context/eventQueue"; import VolumeEditSnapshotBtn from "./VolumeEditSnapshotBtn"; import { useToastNotification } from "context/toastNotificationProvider"; +import ResourceLabel from "components/ResourceLabel"; +import VolumeSnapshotLinkChip from "pages/storage/VolumeSnapshotLinkChip"; interface Props { volume: LxdStorageVolume; @@ -33,15 +35,26 @@ const VolumeSnapshotActions: FC = ({ volume, snapshot }) => { const handleDelete = () => { setDeleting(true); + const snapshotLink = ( + + ); void deleteVolumeSnapshot(volume, snapshot) .then((operation) => eventQueue.set( operation.metadata.id, - () => toastNotify.success(`Snapshot ${snapshot.name} deleted`), + () => + toastNotify.success( + <> + Snapshot{" "} + {" "} + deleted. + , + ), (msg) => toastNotify.failure( `Snapshot ${snapshot.name} deletion failed`, new Error(msg), + snapshotLink, ), () => { setDeleting(false); @@ -54,7 +67,7 @@ const VolumeSnapshotActions: FC = ({ volume, snapshot }) => { ), ) .catch((e) => { - notify.failure("Snapshot deletion failed", e); + notify.failure("Snapshot deletion failed", e, snapshotLink); setDeleting(false); }); }; @@ -63,7 +76,13 @@ const VolumeSnapshotActions: FC = ({ volume, snapshot }) => { setRestoring(true); void restoreVolumeSnapshot(volume, snapshot) .then(() => { - toastNotify.success(`Snapshot ${snapshot.name} restored`); + toastNotify.success( + <> + Snapshot{" "} + {" "} + restored. + , + ); }) .catch((error: Error) => { notify.failure("Snapshot restore failed", error); diff --git a/src/pages/storage/forms/CreateStorageVolume.tsx b/src/pages/storage/forms/CreateStorageVolume.tsx index fa97d38d66..8579e44ac3 100644 --- a/src/pages/storage/forms/CreateStorageVolume.tsx +++ b/src/pages/storage/forms/CreateStorageVolume.tsx @@ -19,6 +19,7 @@ import { slugify } from "util/slugify"; import { POOL } from "../StorageVolumesFilter"; import FormFooterLayout from "components/forms/FormFooterLayout"; import { useToastNotification } from "context/toastNotificationProvider"; +import ResourceLink from "components/ResourceLink"; const CreateStorageVolume: FC = () => { const navigate = useNavigate(); @@ -72,7 +73,17 @@ const CreateStorageVolume: FC = () => { predicate: (query) => query.queryKey[0] === queryKeys.volumes, }); navigate(`/ui/project/${project}/storage/volumes`); - toastNotify.success(`Storage volume ${values.name} created.`); + toastNotify.success( + <> + Storage volume{" "} + {" "} + created. + , + ); }) .catch((e) => { formik.setSubmitting(false); diff --git a/src/pages/storage/forms/CreateVolumeSnapshotForm.tsx b/src/pages/storage/forms/CreateVolumeSnapshotForm.tsx index b4514bdec3..95cc78a5cf 100644 --- a/src/pages/storage/forms/CreateVolumeSnapshotForm.tsx +++ b/src/pages/storage/forms/CreateVolumeSnapshotForm.tsx @@ -12,6 +12,8 @@ import { queryKeys } from "util/queryKeys"; import { SnapshotFormValues, getExpiresAt } from "util/snapshots"; import { getVolumeSnapshotSchema } from "util/storageVolumeSnapshots"; import { useToastNotification } from "context/toastNotificationProvider"; +import { getVolumeSnapshotName } from "util/operations"; +import VolumeSnapshotLinkChip from "../VolumeSnapshotLinkChip"; interface Props { close: () => void; @@ -55,7 +57,16 @@ const CreateVolumeSnapshotForm: FC = ({ close, volume }) => { query.queryKey[0] === queryKeys.volumes || query.queryKey[0] === queryKeys.storage, }); - toastNotify.success(`Snapshot ${values.name} created.`); + toastNotify.success( + <> + Snapshot{" "} + {" "} + created. + , + ); close(); resetForm(); }, diff --git a/src/pages/storage/forms/DuplicateVolumeForm.tsx b/src/pages/storage/forms/DuplicateVolumeForm.tsx index 43608c78e4..56e4ab1800 100644 --- a/src/pages/storage/forms/DuplicateVolumeForm.tsx +++ b/src/pages/storage/forms/DuplicateVolumeForm.tsx @@ -13,15 +13,15 @@ import * as Yup from "yup"; import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { duplicateStorageVolume } from "api/storage-pools"; -import { Link, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { fetchProjects } from "api/projects"; import { useEventQueue } from "context/eventQueue"; import { LxdStorageVolume } from "types/storage"; import { useSupportedFeatures } from "context/useSupportedFeatures"; import { loadCustomVolumes } from "context/loadCustomVolumes"; -import ItemName from "components/ItemName"; import StoragePoolSelector from "../StoragePoolSelector"; import { checkDuplicateName, getUniqueResourceName } from "util/helpers"; +import ResourceLink from "components/ResourceLink"; interface Props { volume: LxdStorageVolume; @@ -53,17 +53,18 @@ const DuplicateVolumeForm: FC = ({ volume, close }) => { }); const notifySuccess = (volumeName: string, project: string, pool: string) => { + const volumeUrl = `/ui/project/${project}/storage/pool/${pool}/volumes/custom/${volumeName}`; const message = ( <> - Created volume {volumeName}. + Created volume{" "} + . ); - const redirectUrl = `/ui/project/${project}/storage/pool/${pool}/volumes/custom/${volumeName}/configuration`; const actions = [ { label: "Configure", - onClick: () => navigate(redirectUrl), + onClick: () => navigate(`${volumeUrl}/configuration`), }, ]; @@ -87,7 +88,7 @@ const DuplicateVolumeForm: FC = ({ volume, close }) => { const { name, project, pool } = values; const notFound = await checkDuplicateName( name, - project || "", + project || "default", controllerState, `storage-pools/${pool}/volumes/custom`, ); @@ -133,12 +134,11 @@ const DuplicateVolumeForm: FC = ({ volume, close }) => { }; const existingVolumeLink = ( - e.stopPropagation()} - > - - + /> ); const targetProject = @@ -146,7 +146,9 @@ const DuplicateVolumeForm: FC = ({ volume, close }) => { duplicateStorageVolume(payload, values.pool, targetProject) .then((operation) => { - toastNotify.info(`Duplication of volume ${volume.name} started.`); + toastNotify.info( + <>Duplication of volume {existingVolumeLink} started., + ); eventQueue.set( operation.metadata.id, () => notifySuccess(values.name, values.project, values.pool), diff --git a/src/pages/storage/forms/EditStorageVolume.tsx b/src/pages/storage/forms/EditStorageVolume.tsx index dd4b83ed34..02fc405df9 100644 --- a/src/pages/storage/forms/EditStorageVolume.tsx +++ b/src/pages/storage/forms/EditStorageVolume.tsx @@ -17,6 +17,7 @@ import { slugify } from "util/slugify"; import FormFooterLayout from "components/forms/FormFooterLayout"; import { useToastNotification } from "context/toastNotificationProvider"; import FormSubmitBtn from "components/forms/FormSubmitBtn"; +import ResourceLink from "components/ResourceLink"; interface Props { volume: LxdStorageVolume; @@ -62,7 +63,17 @@ const EditStorageVolume: FC = ({ volume }) => { saveVolume.name, ], }); - toastNotify.success(`Storage volume ${saveVolume.name} updated.`); + toastNotify.success( + <> + Storage volume{" "} + {" "} + updated. + , + ); }) .catch((e) => { notify.failure("Storage volume update failed", e); diff --git a/src/pages/storage/forms/EditVolumeSnapshotForm.tsx b/src/pages/storage/forms/EditVolumeSnapshotForm.tsx index ab00833a6b..37f53933d7 100644 --- a/src/pages/storage/forms/EditVolumeSnapshotForm.tsx +++ b/src/pages/storage/forms/EditVolumeSnapshotForm.tsx @@ -15,6 +15,7 @@ import { queryKeys } from "util/queryKeys"; import { SnapshotFormValues, getExpiresAt } from "util/snapshots"; import { getVolumeSnapshotSchema } from "util/storageVolumeSnapshots"; import { useToastNotification } from "context/toastNotificationProvider"; +import VolumeSnapshotLinkChip from "../VolumeSnapshotLinkChip"; interface Props { volume: LxdStorageVolume; @@ -35,7 +36,11 @@ const EditVolumeSnapshotForm: FC = ({ volume, snapshot, close }) => { query.queryKey[0] === queryKeys.volumes || query.queryKey[0] === queryKeys.storage, }); - toastNotify.success(`Snapshot ${name} saved.`); + toastNotify.success( + <> + Snapshot saved. + , + ); formik.setSubmitting(false); close(); }; @@ -52,6 +57,9 @@ const EditVolumeSnapshotForm: FC = ({ volume, snapshot, close }) => { }; const rename = (newName: string): Promise => { + const snapshotLink = ( + + ); return new Promise((resolve) => { void renameVolumeSnapshot({ volume, @@ -66,13 +74,14 @@ const EditVolumeSnapshotForm: FC = ({ volume, snapshot, close }) => { toastNotify.failure( `Snapshot ${snapshot.name} rename failed`, new Error(msg), + snapshotLink, ); formik.setSubmitting(false); }, ), ) .catch((e) => { - notify.failure("Snapshot rename failed", e); + notify.failure("Snapshot rename failed", e, snapshotLink); formik.setSubmitting(false); }); }); diff --git a/src/types/instance.d.ts b/src/types/instance.d.ts index 3d68b262be..806cc22583 100644 --- a/src/types/instance.d.ts +++ b/src/types/instance.d.ts @@ -102,6 +102,6 @@ export interface LxdInstance { state?: LxdInstanceState; stateful: boolean; status: LxdInstanceStatus; - type: string; + type: "container" | "virtual-machine"; etag?: string; } diff --git a/src/types/operation.d.ts b/src/types/operation.d.ts index 7172fa5ce7..630f7b2378 100644 --- a/src/types/operation.d.ts +++ b/src/types/operation.d.ts @@ -16,6 +16,7 @@ export interface LxdOperation { resources?: { instances?: string[]; instances_snapshots?: string[]; + storage_volume_snapshots?: string[]; }; status: LxdOperationStatus; status_code: string; diff --git a/src/types/partial.d.ts b/src/types/partial.d.ts new file mode 100644 index 0000000000..04292e9913 --- /dev/null +++ b/src/types/partial.d.ts @@ -0,0 +1 @@ +export type PartialWithRequired = Partial & Pick; diff --git a/src/util/instanceMigration.tsx b/src/util/instanceMigration.tsx index 9796f7bf3c..c810ed5d6c 100644 --- a/src/util/instanceMigration.tsx +++ b/src/util/instanceMigration.tsx @@ -3,11 +3,12 @@ import { useEventQueue } from "context/eventQueue"; import { useInstanceLoading } from "context/instanceLoading"; import { useToastNotification } from "context/toastNotificationProvider"; import { queryKeys } from "./queryKeys"; -import ItemName from "components/ItemName"; import { migrateInstance } from "api/instances"; import { LxdInstance } from "types/instance"; import { ReactNode } from "react"; import { capitalizeFirstLetter } from "./helpers"; +import ResourceLink from "components/ResourceLink"; +import InstanceLinkChip from "pages/instances/InstanceLinkChip"; export type MigrationType = "cluster member" | "root storage pool" | ""; @@ -29,14 +30,18 @@ export const useInstanceMigration = ({ const eventQueue = useEventQueue(); const queryClient = useQueryClient(); - const handleSuccess = (newTarget: string, instanceName: string) => { + const handleSuccess = (newTarget: string) => { let successMessage: ReactNode = ""; if (type === "cluster member") { successMessage = ( <> - Instance successfully + Instance successfully migrated to cluster member{" "} - + ); } @@ -44,9 +49,13 @@ export const useInstanceMigration = ({ if (type === "root storage pool") { successMessage = ( <> - Instance root storage + Instance root storage successfully migrated to pool{" "} - + ); } @@ -54,22 +63,26 @@ export const useInstanceMigration = ({ toastNotify.success(successMessage); }; - const notifyFailure = (e: unknown, instanceName: string) => { + const notifyFailure = (e: unknown) => { let failureMessage = ""; if (type === "cluster member") { - failureMessage = `Cluster member migration failed for instance ${instanceName}`; + failureMessage = `Cluster member migration failed for instance ${instance.name}`; } if (type === "root storage pool") { - failureMessage = `Root storage migration failed for instance ${instanceName}`; + failureMessage = `Root storage migration failed for instance ${instance.name}`; } instanceLoading.setFinish(instance); - toastNotify.failure(failureMessage, e); + toastNotify.failure( + failureMessage, + e, + , + ); }; - const handleFailure = (msg: string, instanceName: string) => { - notifyFailure(new Error(msg), instanceName); + const handleFailure = (msg: string) => { + notifyFailure(new Error(msg)); }; const handleFinish = () => { @@ -87,12 +100,15 @@ export const useInstanceMigration = ({ .then((operation) => { eventQueue.set( operation.metadata.id, - () => handleSuccess(target, instance.name), - (err) => handleFailure(err, instance.name), + () => handleSuccess(target), + (err) => handleFailure(err), handleFinish, ); toastNotify.info( - `${capitalizeFirstLetter(type)} migration started for instance ${instance.name}`, + <> + {capitalizeFirstLetter(type)} migration started for{" "} + . + , ); void queryClient.invalidateQueries({ queryKey: [queryKeys.instances, instance.name, instance.project], @@ -100,7 +116,7 @@ export const useInstanceMigration = ({ onSuccess(); }) .catch((e) => { - notifyFailure(e, instance.name); + notifyFailure(e); }); }; diff --git a/src/util/instanceStart.tsx b/src/util/instanceStart.tsx index 12d15e2559..4ab810d867 100644 --- a/src/util/instanceStart.tsx +++ b/src/util/instanceStart.tsx @@ -1,12 +1,11 @@ import { useQueryClient } from "@tanstack/react-query"; import { unfreezeInstance, startInstance } from "api/instances"; import { useInstanceLoading } from "context/instanceLoading"; -import InstanceLink from "pages/instances/InstanceLink"; import { LxdInstance } from "types/instance"; import { queryKeys } from "./queryKeys"; import { useEventQueue } from "context/eventQueue"; -import ItemName from "components/ItemName"; import { useToastNotification } from "context/toastNotificationProvider"; +import InstanceLinkChip from "pages/instances/InstanceLinkChip"; export const useInstanceStart = (instance: LxdInstance) => { const eventQueue = useEventQueue(); @@ -34,25 +33,21 @@ export const useInstanceStart = (instance: LxdInstance) => { instanceLoading.setLoading(instance, "Starting"); const mutation = instance.status === "Frozen" ? unfreezeInstance : startInstance; + + const instanceLink = ; void mutation(instance) .then((operation) => { eventQueue.set( operation.metadata.id, () => { - toastNotify.success( - <> - Instance started. - , - ); + toastNotify.success(<>Instance {instanceLink} started.); clearCache(); }, (msg) => { toastNotify.failure( "Instance start failed", new Error(msg), - <> - Instance : - , + instanceLink, ); // Delay clearing the cache, because the instance is reported as RUNNING // when a start operation failed, only shortly after it goes back to STOPPED @@ -65,7 +60,7 @@ export const useInstanceStart = (instance: LxdInstance) => { ); }) .catch((e) => { - toastNotify.failure("Instance start failed", e); + toastNotify.failure("Instance start failed", e, instanceLink); instanceLoading.setFinish(instance); }); }; diff --git a/src/util/instances.tsx b/src/util/instances.tsx index 59600fdea2..0df9deb309 100644 --- a/src/util/instances.tsx +++ b/src/util/instances.tsx @@ -1,6 +1,5 @@ import { LxdOperationResponse } from "types/operation"; import { getInstanceName } from "./operations"; -import InstanceLink from "pages/instances/InstanceLink"; import { ReactNode } from "react"; import { AbortControllerState, @@ -8,27 +7,28 @@ import { getFileExtension, } from "./helpers"; import * as Yup from "yup"; - -export const instanceLinkFromName = (args: { - instanceName: string; - project?: string; -}): ReactNode => { - const { project, instanceName } = args; - return ( - - ); -}; +import InstanceLinkChip from "pages/instances/InstanceLinkChip"; +import { InstanceIconType } from "components/ResourceIcon"; export const instanceLinkFromOperation = (args: { operation?: LxdOperationResponse; project?: string; + instanceType: InstanceIconType; }): ReactNode | undefined => { - const { operation, project } = args; + const { operation, project, instanceType } = args; const linkText = getInstanceName(operation?.metadata); if (!linkText) { return; } - return ; + return ( + + ); }; export const instanceNameValidation = ( diff --git a/src/util/operations.spec.ts b/src/util/operations.spec.ts index 2c87a04089..c00de4dab3 100644 --- a/src/util/operations.spec.ts +++ b/src/util/operations.spec.ts @@ -1,11 +1,27 @@ -import { getInstanceName, getProjectName } from "./operations"; +import { + getInstanceName, + getInstanceSnapshotName, + getProjectName, + getVolumeSnapshotName, +} from "./operations"; import { LxdOperation } from "types/operation"; const craftOperation = (...url: string[]) => { const instances: string[] = []; const instances_snapshots: string[] = []; + const storage_volume_snapshots: string[] = []; for (const u of url) { const segments = u.split("/"); + if (u.includes("snapshots") && u.includes("storage-pools")) { + storage_volume_snapshots.push(u); + continue; + } + + if (u.includes("snapshots") && u.includes("instances")) { + instances_snapshots.push(u); + continue; + } + if (segments.length > 4) { instances_snapshots.push(u); } else { @@ -17,6 +33,7 @@ const craftOperation = (...url: string[]) => { resources: { instances, instances_snapshots, + storage_volume_snapshots, }, } as LxdOperation; }; @@ -74,3 +91,43 @@ describe("getProjectName", () => { expect(name).toBe("barProject"); }); }); + +describe("getInstanceSnapshotName", () => { + it("identifies snapshot name from an instance snapshot operation", () => { + const operation = craftOperation( + "/1.0/instances/test-instance/snapshots/test-snapshot", + ); + const name = getInstanceSnapshotName(operation); + + expect(name).toBe("test-snapshot"); + }); + + it("identifies snapshot name from an instance snapshot operation in a custom project", () => { + const operation = craftOperation( + "/1.0/instances/test-instance/snapshots/test-snapshot?project=project", + ); + const name = getInstanceSnapshotName(operation); + + expect(name).toBe("test-snapshot"); + }); +}); + +describe("getVolumeSnapshotName", () => { + it("identifies snapshot name from a volume snapshot operation", () => { + const operation = craftOperation( + "/1.0/storage-pools/test-pool/volumes/custom/test-volume/snapshots/test-snapshot", + ); + const name = getVolumeSnapshotName(operation); + + expect(name).toBe("test-snapshot"); + }); + + it("identifies snapshot name from a volume snapshot operation in a custom project", () => { + const operation = craftOperation( + "/1.0/storage-pools/test-pool/volumes/custom/test-volume/snapshots/test-snapshot?project=project", + ); + const name = getVolumeSnapshotName(operation); + + expect(name).toBe("test-snapshot"); + }); +}); diff --git a/src/util/operations.tsx b/src/util/operations.tsx index b88004b50c..c8f166404f 100644 --- a/src/util/operations.tsx +++ b/src/util/operations.tsx @@ -13,6 +13,28 @@ export const getInstanceName = (operation?: LxdOperation): string => { ); }; +export const getInstanceSnapshotName = (operation?: LxdOperation): string => { + // /1.0/instances//snapshots/ + const instanceSnapshots = operation?.resources?.instances_snapshots ?? []; + if (instanceSnapshots.length) { + return instanceSnapshots[0].split("/")[5].split("?")[0]; + } + + return ""; +}; + +export const getVolumeSnapshotName = (operation?: LxdOperation): string => { + // /1.0/storage-pools//volumes/custom//snapshots/ + const storageVolumeSnapshots = + operation?.resources?.storage_volume_snapshots ?? []; + + if (storageVolumeSnapshots.length) { + return storageVolumeSnapshots[0].split("/")[8].split("?")[0]; + } + + return ""; +}; + export const getProjectName = (operation: LxdOperation): string => { // the url can be // /1.0/instances/?project= diff --git a/tests/iso-volumes.spec.ts b/tests/iso-volumes.spec.ts index 655e039da6..6c7161b1fb 100644 --- a/tests/iso-volumes.spec.ts +++ b/tests/iso-volumes.spec.ts @@ -26,7 +26,7 @@ test("upload and delete custom iso", async ({ page, lxdVersion }) => { await page.getByLabel("Alias").fill(isoName); await page.getByRole("button", { name: "Upload", exact: true }).click(); - await assertTextVisible(page, `Image ${isoName} uploaded successfully`); + await assertTextVisible(page, `Custom ISO ${isoName} uploaded successfully`); await page.getByPlaceholder("Search for custom ISOs").fill(isoName); await page.getByRole("button", { name: "Create instance" }).click(); diff --git a/tests/storage.spec.ts b/tests/storage.spec.ts index 0450177b9d..04c04461c0 100644 --- a/tests/storage.spec.ts +++ b/tests/storage.spec.ts @@ -69,7 +69,7 @@ test("storage volume migrate", async ({ page }) => { await createPool(page, pool2); await migrateVolume(page, volume, pool2); - await expect(page.getByRole("link", { name: pool2 })).toBeVisible(); + await expect(page.getByRole("cell", { name: pool2 })).toBeVisible(); //Migrate back to default so that the Pool can be deleted await migrateVolume(page, volume, "default"); @@ -139,7 +139,8 @@ test("storage pool with driver zfs", async ({ page }) => { const pool = randomPoolName(); await createPool(page, pool, "ZFS"); - await expect(page.getByRole("link", { name: pool })).toBeVisible(); + const poolRow = page.getByRole("row").filter({ hasText: pool }); + await expect(poolRow.getByRole("link", { name: pool })).toBeVisible(); await deletePool(page, pool);