From 105d490d7843c8104869fe1b02106f6a58d3e8dd Mon Sep 17 00:00:00 2001 From: David Edler Date: Thu, 27 Apr 2023 12:12:58 +0200 Subject: [PATCH] wip --- src/api/storages.tsx | 60 ++++++ src/pages/storages/StorageDetail.tsx | 88 ++++++++ src/pages/storages/StorageForm.tsx | 190 ++++++++++++++++++ src/pages/storages/StorageList.tsx | 165 +++++++++++++++ src/pages/storages/StorageSelector.tsx | 87 ++++++++ src/pages/storages/StorageSize.tsx | 36 ++++ src/pages/storages/StorageUsedBy.tsx | 110 ++++++++++ src/pages/storages/actions/AddStorageBtn.tsx | 25 +++ .../storages/actions/DeleteStorageBtn.tsx | 61 ++++++ t | 0 10 files changed, 822 insertions(+) create mode 100644 src/api/storages.tsx create mode 100644 src/pages/storages/StorageDetail.tsx create mode 100644 src/pages/storages/StorageForm.tsx create mode 100644 src/pages/storages/StorageList.tsx create mode 100644 src/pages/storages/StorageSelector.tsx create mode 100644 src/pages/storages/StorageSize.tsx create mode 100644 src/pages/storages/StorageUsedBy.tsx create mode 100644 src/pages/storages/actions/AddStorageBtn.tsx create mode 100644 src/pages/storages/actions/DeleteStorageBtn.tsx create mode 100644 t diff --git a/src/api/storages.tsx b/src/api/storages.tsx new file mode 100644 index 0000000000..20b5d1eb8a --- /dev/null +++ b/src/api/storages.tsx @@ -0,0 +1,60 @@ +import { handleResponse } from "util/helpers"; +import { LxdStorage, LxdStorageResources } from "types/storage"; +import { LxdApiResponse } from "types/apiResponse"; + +export const fetchStorage = ( + storage: string, + project: string +): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/storage-pools/${storage}?project=${project}&recursion=1`) + .then(handleResponse) + .then((data: LxdApiResponse) => resolve(data.metadata)) + .catch(reject); + }); +}; + +export const fetchStorages = (project: string): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/storage-pools?project=${project}&recursion=1`) + .then(handleResponse) + .then((data: LxdApiResponse) => resolve(data.metadata)) + .catch(reject); + }); +}; + +export const fetchStorageResources = ( + storage: string +): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/storage-pools/${storage}/resources`) + .then(handleResponse) + .then((data: LxdApiResponse) => + resolve(data.metadata) + ) + .catch(reject); + }); +}; + +export const createStoragePool = (storage: LxdStorage, project: string) => { + return new Promise((resolve, reject) => { + fetch(`/1.0/storage-pools?project=${project}`, { + method: "POST", + body: JSON.stringify(storage), + }) + .then(handleResponse) + .then((data) => resolve(data)) + .catch(reject); + }); +}; + +export const deleteStoragePool = (name: string, project: string) => { + return new Promise((resolve, reject) => { + fetch(`/1.0/storage-pools/${name}?project=${project}`, { + method: "DELETE", + }) + .then(handleResponse) + .then((data) => resolve(data)) + .catch(reject); + }); +}; diff --git a/src/pages/storages/StorageDetail.tsx b/src/pages/storages/StorageDetail.tsx new file mode 100644 index 0000000000..d7641f5e0a --- /dev/null +++ b/src/pages/storages/StorageDetail.tsx @@ -0,0 +1,88 @@ +import React, { FC } from "react"; +import { useParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import BaseLayout from "components/BaseLayout"; +import NotificationRow from "components/NotificationRow"; +import { queryKeys } from "util/queryKeys"; +import { useNotify } from "context/notify"; +import { Row } from "@canonical/react-components"; +import Loader from "components/Loader"; +import { fetchStorage } from "api/storages"; +import StorageSize from "pages/storages/StorageSize"; +import StorageUsedBy from "pages/storages/StorageUsedBy"; + +const StorageDetail: FC = () => { + const notify = useNotify(); + const { name, project } = useParams<{ + name: string; + project: string; + }>(); + + if (!name) { + return <>Missing name; + } + if (!project) { + return <>Missing project; + } + + const { + data: storage, + error, + isLoading, + } = useQuery({ + queryKey: [queryKeys.storage, project, name], + queryFn: () => fetchStorage(name, project), + }); + + if (error) { + notify.failure("Loading storage details failed", error); + } + + if (isLoading) { + return ; + } else if (!storage) { + return <>Loading storage details failed; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name{storage.name}
Status{storage.status}
Size + +
Source{storage.config?.source ?? "-"}
Description{storage.description ? storage.description : "-"}
Driver{storage.driver}
+

Used by

+ +
+
+ ); +}; + +export default StorageDetail; diff --git a/src/pages/storages/StorageForm.tsx b/src/pages/storages/StorageForm.tsx new file mode 100644 index 0000000000..88d9e7fc47 --- /dev/null +++ b/src/pages/storages/StorageForm.tsx @@ -0,0 +1,190 @@ +import React, { FC, useState } from "react"; +import { + Button, + Col, + Form, + Input, + Row, + Select, +} from "@canonical/react-components"; +import { useFormik } from "formik"; +import * as Yup from "yup"; +import Aside from "components/Aside"; +import NotificationRow from "components/NotificationRow"; +import PanelHeader from "components/PanelHeader"; +import { useNotify } from "context/notify"; +import { useQueryClient } from "@tanstack/react-query"; +import { queryKeys } from "util/queryKeys"; +import SubmitButton from "components/SubmitButton"; +import { checkDuplicateName } from "util/helpers"; +import usePanelParams from "util/usePanelParams"; +import { LxdStorage } from "types/storage"; +import { createStoragePool } from "api/storages"; +import { getSourceHelpForDriver, storageDrivers } from "util/storageOptions"; +import ItemName from "components/ItemName"; + +const StorageForm: FC = () => { + const panelParams = usePanelParams(); + const notify = useNotify(); + const queryClient = useQueryClient(); + const controllerState = useState(null); + + const StorageSchema = Yup.object().shape({ + name: Yup.string() + .test( + "deduplicate", + "A storage pool with this name already exists", + (value) => + checkDuplicateName( + value, + panelParams.project, + controllerState, + "storage-pools" + ) + ) + .required("This field is required"), + }); + + const formik = useFormik({ + initialValues: { + name: "", + description: "", + driver: "zfs", + source: "", + size: "", + }, + validationSchema: StorageSchema, + onSubmit: ({ name, description, driver, source, size }) => { + const storagePool: LxdStorage = { + name, + description, + driver, + source: driver !== "btrfs" ? source : undefined, + config: { + size: size ? `${size}GiB` : undefined, + }, + }; + + createStoragePool(storagePool, panelParams.project) + .then(() => { + void queryClient.invalidateQueries({ + queryKey: [queryKeys.storage], + }); + notify.success( + <> + Storage created. + + ); + panelParams.clear(); + }) + .catch((e) => { + formik.setSubmitting(false); + notify.failure("Storage pool creation failed", e); + }); + }, + }); + + const submitForm = () => { + void formik.submitForm(); + }; + + return ( + + ); +}; + +export default StorageForm; diff --git a/src/pages/storages/StorageList.tsx b/src/pages/storages/StorageList.tsx new file mode 100644 index 0000000000..c503443d4f --- /dev/null +++ b/src/pages/storages/StorageList.tsx @@ -0,0 +1,165 @@ +import React, { FC } from "react"; +import { Icon, MainTable, Row } from "@canonical/react-components"; +import NotificationRow from "components/NotificationRow"; +import { fetchStorages } from "api/storages"; +import BaseLayout from "components/BaseLayout"; +import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "util/queryKeys"; +import { useNotify } from "context/notify"; +import Loader from "components/Loader"; +import { Link, useParams } from "react-router-dom"; +import AddStorageBtn from "pages/storages/actions/AddStorageBtn"; +import DeleteStorageBtn from "pages/storages/actions/DeleteStorageBtn"; +import StorageSize from "pages/storages/StorageSize"; +import EmptyState from "components/EmptyState"; + +const StorageList: FC = () => { + const notify = useNotify(); + const { project } = useParams<{ project: string }>(); + + if (!project) { + return <>Missing project; + } + + const { + data: storages = [], + error, + isLoading, + } = useQuery({ + queryKey: [queryKeys.storage, project], + queryFn: () => fetchStorages(project), + }); + + if (error) { + notify.failure("Loading storage pools failed", error); + } + + const hasStorages = storages.length > 0; + + const headers = [ + { content: "Name", sortKey: "name" }, + { content: "Driver", sortKey: "driver" }, + { content: "Size" }, + { content: "Description", sortKey: "description" }, + { content: "Used by", sortKey: "usedBy", className: "u-align--right" }, + { content: "State", sortKey: "state" }, + { "aria-label": "Actions", className: "u-align--right" }, + ]; + + const rows = storages.map((storage) => { + return { + columns: [ + { + content: ( + + {storage.name} + + ), + role: "rowheader", + "aria-label": "Name", + }, + { + content: storage.driver, + role: "rowheader", + "aria-label": "Driver", + }, + { + content: , + role: "rowheader", + "aria-label": "Size", + }, + { + content: storage.description, + role: "rowheader", + "aria-label": "Description", + }, + { + content: storage.used_by?.length ?? "0", + role: "rowheader", + className: "u-align--right", + "aria-label": "Used by", + }, + { + content: storage.status, + role: "rowheader", + "aria-label": "State", + }, + { + content: , + role: "rowheader", + className: "u-align--right", + "aria-label": "Actions", + }, + ], + sortData: { + name: storage.name.toLowerCase(), + driver: storage.driver, + description: storage.description.toLowerCase(), + state: storage.status, + usedBy: storage.used_by?.length ?? 0, + }, + }; + }); + + return ( + <> + + ) + } + > + + + {hasStorages && ( + + ) : ( + "No data to display" + ) + } + /> + )} + {!isLoading && !hasStorages && ( + + <> +

+ + Learn more about storages + + +

+ + +
+ )} +
+
+ + ); +}; + +export default StorageList; diff --git a/src/pages/storages/StorageSelector.tsx b/src/pages/storages/StorageSelector.tsx new file mode 100644 index 0000000000..b6c83bf68a --- /dev/null +++ b/src/pages/storages/StorageSelector.tsx @@ -0,0 +1,87 @@ +import React, { FC } from "react"; +import { Input, Select } from "@canonical/react-components"; +import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "util/queryKeys"; +import { LxdDiskDevice } from "types/device"; +import { fetchStorages } from "api/storages"; +import Loader from "components/Loader"; +import { useNotify } from "context/notify"; + +interface Props { + project: string; + diskDevice: LxdDiskDevice; + setDiskDevice: (diskDevice: LxdDiskDevice) => void; + hasPathInput?: boolean; +} + +const StorageSelector: FC = ({ + project, + diskDevice: diskDevice, + setDiskDevice: setDiskDevice, + hasPathInput = true, +}) => { + const notify = useNotify(); + const { + data: storagePools = [], + error, + isLoading, + } = useQuery({ + queryKey: [queryKeys.storage], + queryFn: () => fetchStorages(project), + }); + + if (isLoading) { + return ; + } + + if (error) { + notify.failure("Loading storage pools failed", error); + } + + const getStoragePoolOptions = () => { + const options = storagePools.map((storagePool) => { + return { + label: storagePool.name, + value: storagePool.name, + disabled: false, + }; + }); + options.unshift({ + label: + storagePools.length === 0 + ? "No storage pool available" + : "Select option", + value: "", + disabled: true, + }); + return options; + }; + + return ( + <> + + setDiskDevice({ ...diskDevice, path: e.target.value }) + } + value={diskDevice.path} + stacked + /> + )} + + ); +}; + +export default StorageSelector; diff --git a/src/pages/storages/StorageSize.tsx b/src/pages/storages/StorageSize.tsx new file mode 100644 index 0000000000..00666d49ec --- /dev/null +++ b/src/pages/storages/StorageSize.tsx @@ -0,0 +1,36 @@ +import React, { FC } from "react"; +import { fetchStorageResources } from "api/storages"; +import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "util/queryKeys"; +import { LxdStorage } from "types/storage"; +import { humanFileSize } from "util/helpers"; +import Meter from "components/Meter"; + +interface Props { + storage: LxdStorage; +} + +const StorageSize: FC = ({ storage }) => { + const { data: resources } = useQuery({ + queryKey: [queryKeys.storage, storage.name, queryKeys.resources], + queryFn: () => fetchStorageResources(storage.name), + }); + + if (!resources) { + return <>{storage.config?.size}; + } + + const total = resources.space.total; + const used = resources.space.used; + + return ( + <> + + + ); +}; + +export default StorageSize; diff --git a/src/pages/storages/StorageUsedBy.tsx b/src/pages/storages/StorageUsedBy.tsx new file mode 100644 index 0000000000..5694d6099e --- /dev/null +++ b/src/pages/storages/StorageUsedBy.tsx @@ -0,0 +1,110 @@ +import React, { FC, Fragment, useState } from "react"; +import { Link } from "react-router-dom"; +import { List, Tabs } from "@canonical/react-components"; +import ImageName from "pages/images/ImageName"; +import { LxdStorage } from "types/storage"; +import { filterUsedByType, LxdUsedBy } from "util/usedBy"; +import InstanceLink from "pages/instances/InstanceLink"; + +interface Props { + storage: LxdStorage; + project: string; +} + +const INSTANCES = "Instances"; +const PROFILES = "Profiles"; +const IMAGES = "Images"; +const SNAPSHOTS = "Snapshots"; +const TABS = [INSTANCES, PROFILES, IMAGES, SNAPSHOTS]; + +const StorageUsedBy: FC = ({ storage, project }) => { + const [activeTab, setActiveTab] = useState(INSTANCES); + + const data: Record = { + [INSTANCES]: filterUsedByType("instances", project, storage.used_by), + [PROFILES]: filterUsedByType("profiles", project, storage.used_by), + [IMAGES]: filterUsedByType("images", project, storage.used_by), + [SNAPSHOTS]: filterUsedByType("snapshots", project, storage.used_by), + }; + + return ( + <> + ({ + label: `${tab} (${data[tab].length})`, + active: tab === activeTab, + onClick: () => setActiveTab(tab), + }))} + /> + + {activeTab === INSTANCES && + (data[INSTANCES].length ? ( + ( + + + {item.project !== project && ` (project ${item.project})`} + + ))} + /> + ) : ( + <>None + ))} + + {activeTab === PROFILES && + (data[PROFILES].length ? ( + ( + + + {item.name} + + {item.project !== project && ` (project ${item.project})`} + + ))} + /> + ) : ( + <>None + ))} + + {activeTab === IMAGES && + (data[IMAGES].length ? ( + ( + + ))} + /> + ) : ( + <>None + ))} + + {activeTab === SNAPSHOTS && + (data[SNAPSHOTS].length ? ( + ( + + + {`${item.instance} ${item.name}`} + + {item.project !== project && ` (project ${item.project})`} + + ))} + /> + ) : ( + <>None + ))} + + ); +}; + +export default StorageUsedBy; diff --git a/src/pages/storages/actions/AddStorageBtn.tsx b/src/pages/storages/actions/AddStorageBtn.tsx new file mode 100644 index 0000000000..a44df2aa67 --- /dev/null +++ b/src/pages/storages/actions/AddStorageBtn.tsx @@ -0,0 +1,25 @@ +import React, { FC } from "react"; +import { Button } from "@canonical/react-components"; +import usePanelParams from "util/usePanelParams"; + +interface Props { + project: string; + className?: string; +} + +const AddStorageBtn: FC = ({ project, className }) => { + const panelParams = usePanelParams(); + + return ( + + ); +}; + +export default AddStorageBtn; diff --git a/src/pages/storages/actions/DeleteStorageBtn.tsx b/src/pages/storages/actions/DeleteStorageBtn.tsx new file mode 100644 index 0000000000..7b410f7f82 --- /dev/null +++ b/src/pages/storages/actions/DeleteStorageBtn.tsx @@ -0,0 +1,61 @@ +import React, { FC, useState } from "react"; +import ConfirmationButton from "components/ConfirmationButton"; +import { LxdStorage } from "types/storage"; +import { deleteStoragePool } from "api/storages"; +import { useQueryClient } from "@tanstack/react-query"; +import { queryKeys } from "util/queryKeys"; +import { useNotify } from "context/notify"; +import ItemName from "components/ItemName"; + +interface Props { + storage: LxdStorage; + project: string; +} + +const DeleteStorageBtn: FC = ({ storage, project }) => { + const notify = useNotify(); + const [isLoading, setLoading] = useState(false); + const queryClient = useQueryClient(); + + const handleDelete = () => { + setLoading(true); + deleteStoragePool(storage.name, project) + .then(() => { + setLoading(false); + void queryClient.invalidateQueries({ + queryKey: [queryKeys.storage], + }); + notify.success( + <> + Storage pool deleted. + + ); + }) + .catch((e) => { + setLoading(false); + notify.failure("Storage pool deletion failed", e); + }); + }; + + return ( + + Are you sure you want to delete storage{" "} + ?{"\n"}This action cannot be undone, + and can result in data loss. + + } + confirmButtonLabel="Delete" + onConfirm={handleDelete} + isDense={true} + /> + ); +}; + +export default DeleteStorageBtn; diff --git a/t b/t new file mode 100644 index 0000000000..e69de29bb2