From 4ea39ca8ef642a099d1b41005d3c331a826d7dbc Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Fri, 14 Feb 2025 10:38:15 +0200 Subject: [PATCH 01/12] feat: enable storage pool entitlements query Signed-off-by: Mason Hu --- src/api/storage-pools.tsx | 14 +++++++-- src/components/forms/DiskDeviceForm.tsx | 9 ++---- src/context/useStoragePools.tsx | 30 +++++++++++++++++++ .../forms/CreateInstanceFromSnapshotForm.tsx | 10 ++----- .../instances/forms/DuplicateInstanceForm.tsx | 10 ++----- src/pages/storage/CustomVolumeCreateModal.tsx | 10 +++---- src/pages/storage/StoragePoolDetail.tsx | 13 ++------ src/pages/storage/StoragePoolSelectTable.tsx | 9 ++---- src/pages/storage/StoragePoolSelector.tsx | 13 ++------ src/pages/storage/StoragePools.tsx | 13 ++------ src/pages/storage/forms/StorageVolumeForm.tsx | 9 ++---- src/types/storage.d.ts | 1 + src/util/configInheritance.tsx | 13 ++++---- src/util/entitlements/storage-pools.tsx | 14 +++++++++ 14 files changed, 85 insertions(+), 83 deletions(-) create mode 100644 src/context/useStoragePools.tsx create mode 100644 src/util/entitlements/storage-pools.tsx diff --git a/src/api/storage-pools.tsx b/src/api/storage-pools.tsx index 3b4c02ad11..fe4f79a78f 100644 --- a/src/api/storage-pools.tsx +++ b/src/api/storage-pools.tsx @@ -17,23 +17,31 @@ import type { LxdOperationResponse } from "types/operation"; import axios, { AxiosResponse } from "axios"; import type { LxdClusterMember } from "types/cluster"; import { ClusterSpecificValues } from "components/ClusterSpecificSelect"; +import { withEntitlementsQuery } from "util/entitlements/api"; + +export const storagePoolEntitlements = ["can_delete"]; export const fetchStoragePool = ( pool: string, target?: string, + isFineGrained: boolean | null = null, ): Promise => { + const entitlements = `&${withEntitlementsQuery(isFineGrained, storagePoolEntitlements)}`; return new Promise((resolve, reject) => { const targetParam = target ? `&target=${target}` : ""; - fetch(`/1.0/storage-pools/${pool}?recursion=1${targetParam}`) + fetch(`/1.0/storage-pools/${pool}?recursion=1${targetParam}${entitlements}`) .then(handleResponse) .then((data: LxdApiResponse) => resolve(data.metadata)) .catch(reject); }); }; -export const fetchStoragePools = (): Promise => { +export const fetchStoragePools = ( + isFineGrained: boolean | null, +): Promise => { + const entitlements = `&${withEntitlementsQuery(isFineGrained, storagePoolEntitlements)}`; return new Promise((resolve, reject) => { - fetch(`/1.0/storage-pools?recursion=1`) + fetch(`/1.0/storage-pools?recursion=1${entitlements}`) .then(handleResponse) .then((data: LxdApiResponse) => resolve(data.metadata)) .catch(reject); diff --git a/src/components/forms/DiskDeviceForm.tsx b/src/components/forms/DiskDeviceForm.tsx index fe568ae5bb..032a2acb93 100644 --- a/src/components/forms/DiskDeviceForm.tsx +++ b/src/components/forms/DiskDeviceForm.tsx @@ -1,8 +1,5 @@ import { FC } from "react"; import { Input, useNotify } from "@canonical/react-components"; -import { useQuery } from "@tanstack/react-query"; -import { queryKeys } from "util/queryKeys"; -import { fetchStoragePools } from "api/storage-pools"; import { InstanceAndProfileFormikProps } from "./instanceAndProfileFormValues"; import Loader from "components/Loader"; import { getInheritedDiskDevices } from "util/configInheritance"; @@ -12,6 +9,7 @@ import DiskDeviceFormCustom from "./DiskDeviceFormCustom"; import classnames from "classnames"; import ScrollableForm from "components/ScrollableForm"; import { useProfiles } from "context/useProfiles"; +import { useStoragePools } from "context/useStoragePools"; interface Props { formik: InstanceAndProfileFormikProps; @@ -35,10 +33,7 @@ const DiskDeviceForm: FC = ({ formik, project }) => { data: pools = [], isLoading: isStorageLoading, error: storageError, - } = useQuery({ - queryKey: [queryKeys.storage], - queryFn: () => fetchStoragePools(), - }); + } = useStoragePools(); if (storageError) { notify.failure("Loading storage pools failed", storageError); diff --git a/src/context/useStoragePools.tsx b/src/context/useStoragePools.tsx new file mode 100644 index 0000000000..78205142fc --- /dev/null +++ b/src/context/useStoragePools.tsx @@ -0,0 +1,30 @@ +import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "util/queryKeys"; +import { UseQueryResult } from "@tanstack/react-query"; +import { useAuth } from "./auth"; +import { LxdStoragePool } from "types/storage"; +import { fetchStoragePool, fetchStoragePools } from "api/storage-pools"; + +export const useStoragePool = ( + pool: string, + target?: string, + enabled?: boolean, +): UseQueryResult => { + const { isFineGrained } = useAuth(); + return useQuery({ + queryKey: [queryKeys.storage, pool, target], + queryFn: () => fetchStoragePool(pool, target, isFineGrained), + enabled: (enabled ?? true) && isFineGrained !== null, + }); +}; + +export const useStoragePools = ( + enabled?: boolean, +): UseQueryResult => { + const { isFineGrained } = useAuth(); + return useQuery({ + queryKey: [queryKeys.storage], + queryFn: () => fetchStoragePools(isFineGrained), + enabled: (enabled ?? true) && isFineGrained !== null, + }); +}; diff --git a/src/pages/instances/forms/CreateInstanceFromSnapshotForm.tsx b/src/pages/instances/forms/CreateInstanceFromSnapshotForm.tsx index 23bec35887..b551d8a474 100644 --- a/src/pages/instances/forms/CreateInstanceFromSnapshotForm.tsx +++ b/src/pages/instances/forms/CreateInstanceFromSnapshotForm.tsx @@ -14,9 +14,6 @@ import * as Yup from "yup"; import { createInstance } from "api/instances"; import { isClusteredServer } from "util/settings"; import { useSettings } from "context/useSettings"; -import { useQuery } from "@tanstack/react-query"; -import { queryKeys } from "util/queryKeys"; -import { fetchStoragePools } from "api/storage-pools"; import { instanceNameValidation, truncateInstanceName } from "util/instances"; import type { LxdDiskDevice } from "types/device"; import { useEventQueue } from "context/eventQueue"; @@ -27,6 +24,7 @@ import { InstanceIconType } from "components/ResourceIcon"; import { useInstances } from "context/useInstances"; import { useProjects } from "context/useProjects"; import { useProjectEntitlements } from "util/entitlements/projects"; +import { useStoragePools } from "context/useStoragePools"; interface Props { instance: LxdInstance; @@ -95,10 +93,8 @@ const CreateInstanceFromSnapshotForm: FC = ({ const { data: projects = [], isLoading: projectsLoading } = useProjects(); const { canCreateInstances } = useProjectEntitlements(); - const { data: storagePools = [], isLoading: storagePoolsLoading } = useQuery({ - queryKey: [queryKeys.storage], - queryFn: () => fetchStoragePools(), - }); + const { data: storagePools = [], isLoading: storagePoolsLoading } = + useStoragePools(); const { data: instances = [] } = useInstances(instance.project); diff --git a/src/pages/instances/forms/DuplicateInstanceForm.tsx b/src/pages/instances/forms/DuplicateInstanceForm.tsx index ab3eb80aa8..8bbeaa56b9 100644 --- a/src/pages/instances/forms/DuplicateInstanceForm.tsx +++ b/src/pages/instances/forms/DuplicateInstanceForm.tsx @@ -14,9 +14,6 @@ import * as Yup from "yup"; import { createInstance } from "api/instances"; import { isClusteredServer } from "util/settings"; import { useSettings } from "context/useSettings"; -import { useQuery } from "@tanstack/react-query"; -import { queryKeys } from "util/queryKeys"; -import { fetchStoragePools } from "api/storage-pools"; import { useNavigate } from "react-router-dom"; import { instanceNameValidation, truncateInstanceName } from "util/instances"; import type { LxdDiskDevice } from "types/device"; @@ -30,6 +27,7 @@ import StoragePoolSelector from "pages/storage/StoragePoolSelector"; import { useInstances } from "context/useInstances"; import { useProjects } from "context/useProjects"; import { useProjectEntitlements } from "util/entitlements/projects"; +import { useStoragePools } from "context/useStoragePools"; interface Props { instance: LxdInstance; @@ -57,10 +55,8 @@ const DuplicateInstanceForm: FC = ({ instance, close }) => { const { data: projects = [], isLoading: projectsLoading } = useProjects(); const { canCreateInstances } = useProjectEntitlements(); - const { data: storagePools = [], isLoading: storagePoolsLoading } = useQuery({ - queryKey: [queryKeys.storage], - queryFn: () => fetchStoragePools(), - }); + const { data: storagePools = [], isLoading: storagePoolsLoading } = + useStoragePools(); const { data: instances = [] } = useInstances(instance.project); diff --git a/src/pages/storage/CustomVolumeCreateModal.tsx b/src/pages/storage/CustomVolumeCreateModal.tsx index 7fc88d5cd7..16aca3a92d 100644 --- a/src/pages/storage/CustomVolumeCreateModal.tsx +++ b/src/pages/storage/CustomVolumeCreateModal.tsx @@ -5,16 +5,17 @@ import { volumeFormToPayload, } from "pages/storage/forms/StorageVolumeForm"; import { useFormik } from "formik"; -import { createStorageVolume, fetchStoragePools } from "api/storage-pools"; +import { createStorageVolume } from "api/storage-pools"; import { queryKeys } from "util/queryKeys"; import * as Yup from "yup"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import StorageVolumeFormMain from "pages/storage/forms/StorageVolumeFormMain"; import { updateMaxHeight } from "util/updateMaxHeight"; import useEventListener from "util/useEventListener"; import { testDuplicateStorageVolumeName } from "util/storageVolume"; import type { LxdStorageVolume } from "types/storage"; import { useSettings } from "context/useSettings"; +import { useStoragePools } from "context/useStoragePools"; interface Props { project: string; @@ -35,10 +36,7 @@ const CustomVolumeCreateModal: FC = ({ const { data: settings } = useSettings(); - const { data: pools = [] } = useQuery({ - queryKey: [queryKeys.storage], - queryFn: () => fetchStoragePools(), - }); + const { data: pools = [] } = useStoragePools(); const StorageVolumeSchema = Yup.object().shape({ name: Yup.string() diff --git a/src/pages/storage/StoragePoolDetail.tsx b/src/pages/storage/StoragePoolDetail.tsx index 9cbdab23b0..b57c9b853c 100644 --- a/src/pages/storage/StoragePoolDetail.tsx +++ b/src/pages/storage/StoragePoolDetail.tsx @@ -1,10 +1,7 @@ import { FC } from "react"; import { useParams } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; -import { queryKeys } from "util/queryKeys"; import { Icon, Row, useNotify } from "@canonical/react-components"; import Loader from "components/Loader"; -import { fetchStoragePool } from "api/storage-pools"; import StoragePoolHeader from "pages/storage/StoragePoolHeader"; import NotificationRow from "components/NotificationRow"; import StoragePoolOverview from "pages/storage/StoragePoolOverview"; @@ -13,6 +10,7 @@ import EditStoragePool from "pages/storage/EditStoragePool"; import { useClusterMembers } from "context/useClusterMembers"; import TabLinks from "components/TabLinks"; import { TabLink } from "@canonical/react-components/dist/components/Tabs/Tabs"; +import { useStoragePool } from "context/useStoragePools"; const StoragePoolDetail: FC = () => { const notify = useNotify(); @@ -32,14 +30,7 @@ const StoragePoolDetail: FC = () => { const member = clusterMembers[0]?.server_name ?? undefined; - const { - data: pool, - error, - isLoading, - } = useQuery({ - queryKey: [queryKeys.storage, name, member], - queryFn: () => fetchStoragePool(name, member), - }); + const { data: pool, error, isLoading } = useStoragePool(name, member); if (error) { notify.failure("Loading storage details failed", error); diff --git a/src/pages/storage/StoragePoolSelectTable.tsx b/src/pages/storage/StoragePoolSelectTable.tsx index 4e7ebf7327..c1d814f2ac 100644 --- a/src/pages/storage/StoragePoolSelectTable.tsx +++ b/src/pages/storage/StoragePoolSelectTable.tsx @@ -2,10 +2,8 @@ import { FC } from "react"; import { Button, MainTable } from "@canonical/react-components"; import ScrollableTable from "components/ScrollableTable"; import StoragePoolSize from "pages/storage/StoragePoolSize"; -import { useQuery } from "@tanstack/react-query"; -import { queryKeys } from "util/queryKeys"; -import { fetchStoragePools } from "api/storage-pools"; import classnames from "classnames"; +import { useStoragePools } from "context/useStoragePools"; interface Props { onSelect: (pool: string) => void; @@ -16,10 +14,7 @@ interface Props { } const StoragePoolSelectTable: FC = ({ onSelect, disablePool }) => { - const { data: pools = [], isLoading } = useQuery({ - queryKey: [queryKeys.storage], - queryFn: () => fetchStoragePools(), - }); + const { data: pools = [], isLoading } = useStoragePools(); const headers = [ { content: "Name", sortKey: "name" }, diff --git a/src/pages/storage/StoragePoolSelector.tsx b/src/pages/storage/StoragePoolSelector.tsx index c4848862ca..52beb26a4c 100644 --- a/src/pages/storage/StoragePoolSelector.tsx +++ b/src/pages/storage/StoragePoolSelector.tsx @@ -4,15 +4,13 @@ import { CustomSelectOption, useNotify, } from "@canonical/react-components"; -import { useQuery } from "@tanstack/react-query"; -import { queryKeys } from "util/queryKeys"; -import { fetchStoragePools } from "api/storage-pools"; import Loader from "components/Loader"; import { Props as SelectProps } from "@canonical/react-components/dist/components/Select/Select"; import { useSettings } from "context/useSettings"; import { getSupportedStorageDrivers } from "util/storageOptions"; import StoragePoolOptionLabel from "./StoragePoolOptionLabel"; import StoragePoolOptionHeader from "./StoragePoolOptionHeader"; +import { useStoragePools } from "context/useStoragePools"; interface Props { value: string; @@ -29,14 +27,7 @@ const StoragePoolSelector: FC = ({ }) => { const notify = useNotify(); const { data: settings } = useSettings(); - const { - data: pools = [], - error, - isLoading, - } = useQuery({ - queryKey: [queryKeys.storage], - queryFn: () => fetchStoragePools(), - }); + const { data: pools = [], error, isLoading } = useStoragePools(); const supportedStorageDrivers = getSupportedStorageDrivers(settings); const poolsWithSupportedDriver = pools.filter((pool) => diff --git a/src/pages/storage/StoragePools.tsx b/src/pages/storage/StoragePools.tsx index 9443de45d6..0da4415956 100644 --- a/src/pages/storage/StoragePools.tsx +++ b/src/pages/storage/StoragePools.tsx @@ -6,9 +6,6 @@ import { Row, useNotify, } from "@canonical/react-components"; -import { fetchStoragePools } from "api/storage-pools"; -import { useQuery } from "@tanstack/react-query"; -import { queryKeys } from "util/queryKeys"; import Loader from "components/Loader"; import { Link, useParams } from "react-router-dom"; import DeleteStoragePoolBtn from "pages/storage/actions/DeleteStoragePoolBtn"; @@ -21,6 +18,7 @@ import HelpLink from "components/HelpLink"; import NotificationRow from "components/NotificationRow"; import CustomLayout from "components/CustomLayout"; import PageHeader from "components/PageHeader"; +import { useStoragePools } from "context/useStoragePools"; const StoragePools: FC = () => { const docBaseLink = useDocs(); @@ -31,14 +29,7 @@ const StoragePools: FC = () => { return <>Missing project; } - const { - data: pools = [], - error, - isLoading, - } = useQuery({ - queryKey: [queryKeys.storage], - queryFn: () => fetchStoragePools(), - }); + const { data: pools = [], error, isLoading } = useStoragePools(); if (error) { notify.failure("Loading storage pools failed", error); diff --git a/src/pages/storage/forms/StorageVolumeForm.tsx b/src/pages/storage/forms/StorageVolumeForm.tsx index dd5c06f38f..24c175e3c3 100644 --- a/src/pages/storage/forms/StorageVolumeForm.tsx +++ b/src/pages/storage/forms/StorageVolumeForm.tsx @@ -1,8 +1,5 @@ import { FC, ReactNode, useEffect } from "react"; import { Col, Form, Input, Row, useNotify } from "@canonical/react-components"; -import { useQuery } from "@tanstack/react-query"; -import { queryKeys } from "util/queryKeys"; -import { fetchStoragePools } from "api/storage-pools"; import { useParams } from "react-router-dom"; import { updateMaxHeight } from "util/updateMaxHeight"; import useEventListener from "util/useEventListener"; @@ -31,6 +28,7 @@ import { import { slugify } from "util/slugify"; import { driversWithFilesystemSupport } from "util/storageOptions"; import { getUnhandledKeyValues } from "util/formFields"; +import { useStoragePools } from "context/useStoragePools"; export interface StorageVolumeFormValues { name: string; @@ -139,10 +137,7 @@ const StorageVolumeForm: FC = ({ formik, section, setSection }) => { return <>Missing project; } - const { data: pools = [], error } = useQuery({ - queryKey: [queryKeys.storage], - queryFn: () => fetchStoragePools(), - }); + const { data: pools = [], error } = useStoragePools(); if (error) { notify.failure("Loading storage pools failed", error); diff --git a/src/types/storage.d.ts b/src/types/storage.d.ts index 07f51defdc..bb90fca3c2 100644 --- a/src/types/storage.d.ts +++ b/src/types/storage.d.ts @@ -12,6 +12,7 @@ export interface LxdStoragePool { source?: string; status?: string; used_by?: string[]; + access_entitlements?: string[]; } export type LxdStorageVolumeContentType = "filesystem" | "block" | "iso"; diff --git a/src/util/configInheritance.tsx b/src/util/configInheritance.tsx index 0b36d7f8a1..898df5d44d 100644 --- a/src/util/configInheritance.tsx +++ b/src/util/configInheritance.tsx @@ -30,7 +30,6 @@ import { getInstanceKey } from "util/instanceConfigFields"; import { useParams } from "react-router-dom"; import { getProjectKey } from "util/projectConfigFields"; import { StorageVolumeFormValues } from "pages/storage/forms/StorageVolumeForm"; -import { fetchStoragePool } from "api/storage-pools"; import { getVolumeKey } from "util/storageVolume"; import { getNetworkKey, networkFormTypeToOptionKey } from "util/networks"; import { getPoolKey, storagePoolFormDriverToOptionKey } from "./storagePool"; @@ -39,6 +38,7 @@ import { useSupportedFeatures } from "context/useSupportedFeatures"; import { NetworkFormValues } from "pages/networks/forms/NetworkForm"; import { useSettings } from "context/useSettings"; import { useProfiles } from "context/useProfiles"; +import { useStoragePool } from "context/useStoragePools"; export interface ConfigRowMetadata { value?: string; @@ -128,12 +128,13 @@ const getStorageVolumeRowMetadata = ( values: StorageVolumeFormValues, name: string, ): ConfigRowMetadata => { + const storagePoolQueryEnabled = values.isCreating; // when creating the defaults will be taken from the storage pool, if set there - const { data: pool } = useQuery({ - queryKey: [queryKeys.storage, values.pool], - queryFn: () => fetchStoragePool(values.pool), - enabled: values.isCreating, - }); + const { data: pool } = useStoragePool( + values.pool, + undefined, + storagePoolQueryEnabled, + ); const poolField = `volume.${getVolumeKey(name)}`; if (pool?.config && poolField in pool.config) { return { value: pool.config[poolField], source: `${pool.name} pool` }; diff --git a/src/util/entitlements/storage-pools.tsx b/src/util/entitlements/storage-pools.tsx new file mode 100644 index 0000000000..a055edc25a --- /dev/null +++ b/src/util/entitlements/storage-pools.tsx @@ -0,0 +1,14 @@ +import { useAuth } from "context/auth"; +import { hasEntitlement } from "./helpers"; +import { LxdStoragePool } from "types/storage"; + +export const useInstanceEntitlements = () => { + const { isFineGrained } = useAuth(); + + const canDeletePool = (pool: LxdStoragePool) => + hasEntitlement(isFineGrained, "can_delete", pool?.access_entitlements); + + return { + canDeletePool, + }; +}; From b8afeddea0559143e0b11fa022a8519b83605f71 Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Fri, 14 Feb 2025 11:13:10 +0200 Subject: [PATCH 02/12] feat: enable storage volume entitlement queries Signed-off-by: Mason Hu --- src/api/storage-pools.tsx | 15 ++++- src/context/loadCustomVolumes.tsx | 7 ++- src/context/loadIsoVolumes.tsx | 19 ++++-- src/context/useVolumes.tsx | 63 +++++++++++++++++++ src/pages/images/CustomIsoSelector.tsx | 11 +--- src/pages/storage/CustomIsoList.tsx | 11 +--- src/pages/storage/CustomVolumeSelectModal.tsx | 10 +-- src/pages/storage/StorageVolumeDetail.tsx | 9 +-- src/pages/storage/StorageVolumes.tsx | 15 +---- .../storage/forms/DuplicateVolumeForm.tsx | 12 +--- 10 files changed, 108 insertions(+), 64 deletions(-) create mode 100644 src/context/useVolumes.tsx diff --git a/src/api/storage-pools.tsx b/src/api/storage-pools.tsx index fe4f79a78f..3dbfdab255 100644 --- a/src/api/storage-pools.tsx +++ b/src/api/storage-pools.tsx @@ -20,6 +20,7 @@ import { ClusterSpecificValues } from "components/ClusterSpecificSelect"; import { withEntitlementsQuery } from "util/entitlements/api"; export const storagePoolEntitlements = ["can_delete"]; +export const storageVolumeEntitlements = ["can_delete"]; export const fetchStoragePool = ( pool: string, @@ -243,9 +244,13 @@ export const fetchPoolFromClusterMembers = ( export const fetchStorageVolumes = ( pool: string, project: string, + isFineGrained: boolean | null, ): Promise => { + const entitlements = `&${withEntitlementsQuery(isFineGrained, storageVolumeEntitlements)}`; return new Promise((resolve, reject) => { - fetch(`/1.0/storage-pools/${pool}/volumes?project=${project}&recursion=1`) + fetch( + `/1.0/storage-pools/${pool}/volumes?project=${project}&recursion=1${entitlements}`, + ) .then(handleResponse) .then((data: LxdApiResponse) => resolve(data.metadata.map((volume) => ({ ...volume, pool }))), @@ -256,9 +261,11 @@ export const fetchStorageVolumes = ( export const fetchAllStorageVolumes = ( project: string, + isFineGrained: boolean | null, ): Promise => { + const entitlements = `&${withEntitlementsQuery(isFineGrained, storageVolumeEntitlements)}`; return new Promise((resolve, reject) => { - fetch(`/1.0/storage-volumes?recursion=1&project=${project}`) + fetch(`/1.0/storage-volumes?recursion=1&project=${project}${entitlements}`) .then(handleResponse) .then((data: LxdApiResponse) => resolve(data.metadata), @@ -272,10 +279,12 @@ export const fetchStorageVolume = ( project: string, type: string, volume: string, + isFineGrained: boolean | null, ): Promise => { + const entitlements = `&${withEntitlementsQuery(isFineGrained, storageVolumeEntitlements)}`; return new Promise((resolve, reject) => { fetch( - `/1.0/storage-pools/${pool}/volumes/${type}/${volume}?project=${project}&recursion=1`, + `/1.0/storage-pools/${pool}/volumes/${type}/${volume}?project=${project}&recursion=1${entitlements}`, ) .then(handleEtagResponse) .then((data) => resolve({ ...data, pool } as LxdStorageVolume)) diff --git a/src/context/loadCustomVolumes.tsx b/src/context/loadCustomVolumes.tsx index e9c432ea67..38e383444c 100644 --- a/src/context/loadCustomVolumes.tsx +++ b/src/context/loadCustomVolumes.tsx @@ -5,10 +5,15 @@ import { loadVolumes } from "context/loadIsoVolumes"; export const loadCustomVolumes = async ( project: string, hasStorageVolumesAll: boolean, + isFineGrained: boolean | null, ): Promise => { const result: LxdStorageVolume[] = []; - const volumes = await loadVolumes(project, hasStorageVolumesAll); + const volumes = await loadVolumes( + project, + hasStorageVolumesAll, + isFineGrained, + ); volumes.forEach((volume) => { const contentTypes = ["filesystem", "block"]; const isFilesystemOrBlock = contentTypes.includes(volume.content_type); diff --git a/src/context/loadIsoVolumes.tsx b/src/context/loadIsoVolumes.tsx index 57734b0daf..fb6556b948 100644 --- a/src/context/loadIsoVolumes.tsx +++ b/src/context/loadIsoVolumes.tsx @@ -10,9 +10,14 @@ import type { LxdStorageVolume } from "types/storage"; export const loadIsoVolumes = async ( project: string, hasStorageVolumesAll: boolean, + isFineGrained: boolean | null, ): Promise => { const remoteImages: RemoteImage[] = []; - const allVolumes = await loadVolumes(project, hasStorageVolumesAll); + const allVolumes = await loadVolumes( + project, + hasStorageVolumesAll, + isFineGrained, + ); allVolumes.forEach((volume) => { if (volume.content_type === "iso") { const image = isoToRemoteImage(volume); @@ -26,20 +31,24 @@ export const loadIsoVolumes = async ( export const loadVolumes = async ( project: string, hasStorageVolumesAll: boolean, + isFineGrained: boolean | null, ): Promise => { return hasStorageVolumesAll - ? fetchAllStorageVolumes(project) - : collectAllStorageVolumes(project); + ? fetchAllStorageVolumes(project, isFineGrained) + : collectAllStorageVolumes(project, isFineGrained); }; export const collectAllStorageVolumes = async ( project: string, + isFineGrained: boolean | null, ): Promise => { const allVolumes: LxdStorageVolume[] = []; - const pools = await fetchStoragePools(); + const pools = await fetchStoragePools(isFineGrained); const poolVolumes = await Promise.allSettled( - pools.map(async (pool) => fetchStorageVolumes(pool.name, project)), + pools.map(async (pool) => + fetchStorageVolumes(pool.name, project, isFineGrained), + ), ); poolVolumes.forEach((result, index) => { diff --git a/src/context/useVolumes.tsx b/src/context/useVolumes.tsx new file mode 100644 index 0000000000..7253ffaaf1 --- /dev/null +++ b/src/context/useVolumes.tsx @@ -0,0 +1,63 @@ +import { queryKeys } from "util/queryKeys"; +import { useAuth } from "./auth"; +import { useSupportedFeatures } from "./useSupportedFeatures"; +import { + useQuery, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; +import { RemoteImage } from "types/image"; +import { loadIsoVolumes, loadVolumes } from "./loadIsoVolumes"; +import { loadCustomVolumes } from "./loadCustomVolumes"; +import { LxdStorageVolume } from "types/storage"; +import { fetchStorageVolume } from "api/storage-pools"; + +export const useLoadVolumes = ( + project: string, +): UseQueryResult => { + const { isFineGrained } = useAuth(); + const { hasStorageVolumesAll } = useSupportedFeatures(); + return useQuery({ + queryKey: [queryKeys.volumes, project], + queryFn: () => loadVolumes(project, hasStorageVolumesAll, isFineGrained), + }); +}; + +export const useLoadIsoVolumes = ( + project: string, +): UseQueryResult => { + const { isFineGrained } = useAuth(); + const { hasStorageVolumesAll } = useSupportedFeatures(); + return useQuery({ + queryKey: [queryKeys.isoVolumes, project], + queryFn: () => loadIsoVolumes(project, hasStorageVolumesAll, isFineGrained), + }); +}; + +export const useLoadCustomVolumes = ( + project: string, + options?: Partial>, +): UseQueryResult => { + const { isFineGrained } = useAuth(); + const { hasStorageVolumesAll } = useSupportedFeatures(); + return useQuery({ + queryKey: [queryKeys.customVolumes, project], + queryFn: () => + loadCustomVolumes(project, hasStorageVolumesAll, isFineGrained), + ...options, + }); +}; + +export const useStorageVolume = ( + pool: string, + project: string, + type: string, + volume: string, +): UseQueryResult => { + const { isFineGrained } = useAuth(); + return useQuery({ + queryKey: [queryKeys.storage, pool, project, type, volume], + queryFn: () => + fetchStorageVolume(pool, project, type, volume, isFineGrained), + }); +}; diff --git a/src/pages/images/CustomIsoSelector.tsx b/src/pages/images/CustomIsoSelector.tsx index 6b1de13bd0..8ea6b0a9e2 100644 --- a/src/pages/images/CustomIsoSelector.tsx +++ b/src/pages/images/CustomIsoSelector.tsx @@ -1,14 +1,11 @@ import { FC } from "react"; import { Button, MainTable } from "@canonical/react-components"; import { humanFileSize, isoTimeToString } from "util/helpers"; -import { useQuery } from "@tanstack/react-query"; -import { loadIsoVolumes } from "context/loadIsoVolumes"; -import { queryKeys } from "util/queryKeys"; import Loader from "components/Loader"; import { useCurrentProject } from "context/useCurrentProject"; import type { LxdImageType, RemoteImage } from "types/image"; import type { IsoImage } from "types/iso"; -import { useSupportedFeatures } from "context/useSupportedFeatures"; +import { useLoadIsoVolumes } from "context/useVolumes"; interface Props { primaryImage: IsoImage | null; @@ -25,12 +22,8 @@ const CustomIsoSelector: FC = ({ }) => { const { project } = useCurrentProject(); const projectName = project?.name ?? ""; - const { hasStorageVolumesAll } = useSupportedFeatures(); - const { data: images = [], isLoading } = useQuery({ - queryKey: [queryKeys.isoVolumes, project], - queryFn: () => loadIsoVolumes(projectName, hasStorageVolumesAll), - }); + const { data: images = [], isLoading } = useLoadIsoVolumes(projectName); const headers = [ { content: "Name", sortKey: "name" }, diff --git a/src/pages/storage/CustomIsoList.tsx b/src/pages/storage/CustomIsoList.tsx index 69215e1a49..5ae18889ef 100644 --- a/src/pages/storage/CustomIsoList.tsx +++ b/src/pages/storage/CustomIsoList.tsx @@ -9,10 +9,7 @@ import { TablePagination, } from "@canonical/react-components"; import { humanFileSize, isoTimeToString } from "util/helpers"; -import { useQuery } from "@tanstack/react-query"; -import { loadIsoVolumes } from "context/loadIsoVolumes"; import DeleteStorageVolumeBtn from "pages/storage/actions/DeleteStorageVolumeBtn"; -import { queryKeys } from "util/queryKeys"; import Loader from "components/Loader"; import CreateInstanceFromImageBtn from "pages/images/actions/CreateInstanceFromImageBtn"; import UploadCustomIsoBtn from "pages/images/actions/UploadCustomIsoBtn"; @@ -21,16 +18,15 @@ import { Link, useParams } from "react-router-dom"; import { useDocs } from "context/useDocs"; import useSortTableData from "util/useSortTableData"; import { useToastNotification } from "context/toastNotificationProvider"; -import { useSupportedFeatures } from "context/useSupportedFeatures"; 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"; +import { useLoadIsoVolumes } from "context/useVolumes"; const CustomIsoList: FC = () => { const docBaseLink = useDocs(); - const { hasStorageVolumesAll } = useSupportedFeatures(); const toastNotify = useToastNotification(); const [query, setQuery] = useState(""); const { project } = useParams<{ @@ -41,10 +37,7 @@ const CustomIsoList: FC = () => { return <>Missing project; } - const { data: images = [], isLoading } = useQuery({ - queryKey: [queryKeys.isoVolumes, project], - queryFn: () => loadIsoVolumes(project, hasStorageVolumesAll), - }); + const { data: images = [], isLoading } = useLoadIsoVolumes(project); const headers = [ { content: "Name", sortKey: "name" }, diff --git a/src/pages/storage/CustomVolumeSelectModal.tsx b/src/pages/storage/CustomVolumeSelectModal.tsx index 6e90fd1061..95e9fa489a 100644 --- a/src/pages/storage/CustomVolumeSelectModal.tsx +++ b/src/pages/storage/CustomVolumeSelectModal.tsx @@ -1,17 +1,14 @@ import { FC } from "react"; import { Button, MainTable, useNotify } from "@canonical/react-components"; -import { useQuery } from "@tanstack/react-query"; -import { queryKeys } from "util/queryKeys"; import Loader from "components/Loader"; -import { loadCustomVolumes } from "context/loadCustomVolumes"; import ScrollableTable from "components/ScrollableTable"; import type { LxdStorageVolume } from "types/storage"; import NotificationRow from "components/NotificationRow"; import { renderContentType } from "util/storageVolume"; -import { useSupportedFeatures } from "context/useSupportedFeatures"; import classnames from "classnames"; import { useProjectEntitlements } from "util/entitlements/projects"; import { useProject } from "context/useProjects"; +import { useLoadCustomVolumes } from "context/useVolumes"; interface Props { project: string; @@ -33,7 +30,6 @@ const CustomVolumeSelectModal: FC = ({ hasPrevStep, }) => { const notify = useNotify(); - const { hasStorageVolumesAll } = useSupportedFeatures(); const { canCreateStorageVolumes } = useProjectEntitlements(); const { data: currentProject } = useProject(project); @@ -42,10 +38,8 @@ const CustomVolumeSelectModal: FC = ({ error, isLoading, isFetching, - } = useQuery({ - queryKey: [queryKeys.customVolumes, project], + } = useLoadCustomVolumes(project, { refetchOnMount: (query) => query.state.isInvalidated, - queryFn: () => loadCustomVolumes(project, hasStorageVolumesAll), }); if (error) { diff --git a/src/pages/storage/StorageVolumeDetail.tsx b/src/pages/storage/StorageVolumeDetail.tsx index e0c82ac32e..654edf5dcc 100644 --- a/src/pages/storage/StorageVolumeDetail.tsx +++ b/src/pages/storage/StorageVolumeDetail.tsx @@ -1,7 +1,5 @@ import { FC } from "react"; import { useParams } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; -import { queryKeys } from "util/queryKeys"; import { Row, useNotify } from "@canonical/react-components"; import Loader from "components/Loader"; import NotificationRow from "components/NotificationRow"; @@ -11,7 +9,7 @@ import EditStorageVolume from "pages/storage/forms/EditStorageVolume"; import TabLinks from "components/TabLinks"; import CustomLayout from "components/CustomLayout"; import StorageVolumeSnapshots from "./StorageVolumeSnapshots"; -import { fetchStorageVolume } from "api/storage-pools"; +import { useStorageVolume } from "context/useVolumes"; const tabs: string[] = ["Overview", "Configuration", "Snapshots"]; @@ -48,10 +46,7 @@ const StorageVolumeDetail: FC = () => { data: volume, error, isLoading, - } = useQuery({ - queryKey: [queryKeys.storage, pool, project, type, volumeName], - queryFn: () => fetchStorageVolume(pool, project, type, volumeName), - }); + } = useStorageVolume(pool, project, type, volumeName); if (error) { notify.failure("Loading storage volume failed", error); diff --git a/src/pages/storage/StorageVolumes.tsx b/src/pages/storage/StorageVolumes.tsx index 84a2a59bf7..282de31190 100644 --- a/src/pages/storage/StorageVolumes.tsx +++ b/src/pages/storage/StorageVolumes.tsx @@ -1,7 +1,5 @@ import { FC, useState } from "react"; import { Link, useParams, useSearchParams } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; -import { queryKeys } from "util/queryKeys"; import { EmptyState, Icon, @@ -12,7 +10,6 @@ import { } from "@canonical/react-components"; import Loader from "components/Loader"; import { isoTimeToString } from "util/helpers"; -import { loadVolumes } from "context/loadIsoVolumes"; import ScrollableTable from "components/ScrollableTable"; import CreateVolumeBtn from "pages/storage/actions/CreateVolumeBtn"; import StorageVolumesFilter, { @@ -48,11 +45,11 @@ import StorageVolumeNameLink from "./StorageVolumeNameLink"; import CustomStorageVolumeActions from "./actions/CustomStorageVolumeActions"; import useEventListener from "util/useEventListener"; import useSortTableData from "util/useSortTableData"; -import { useSupportedFeatures } from "context/useSupportedFeatures"; import CustomLayout from "components/CustomLayout"; import PageHeader from "components/PageHeader"; import HelpLink from "components/HelpLink"; import NotificationRow from "components/NotificationRow"; +import { useLoadVolumes } from "context/useVolumes"; const StorageVolumes: FC = () => { const docBaseLink = useDocs(); @@ -60,7 +57,6 @@ const StorageVolumes: FC = () => { const { project } = useParams<{ project: string }>(); const [searchParams] = useSearchParams(); const [isSmallScreen, setSmallScreen] = useState(figureCollapsedScreen()); - const { hasStorageVolumesAll } = useSupportedFeatures(); const resize = () => { setSmallScreen(figureCollapsedScreen()); }; @@ -81,14 +77,7 @@ const StorageVolumes: FC = () => { return <>Missing project; } - const { - data: volumes = [], - error, - isLoading, - } = useQuery({ - queryKey: [queryKeys.volumes, project], - queryFn: () => loadVolumes(project, hasStorageVolumesAll), - }); + const { data: volumes = [], error, isLoading } = useLoadVolumes(project); if (error) { notify.failure("Loading storage volumes failed", error); diff --git a/src/pages/storage/forms/DuplicateVolumeForm.tsx b/src/pages/storage/forms/DuplicateVolumeForm.tsx index f8b4d93935..6585dc033d 100644 --- a/src/pages/storage/forms/DuplicateVolumeForm.tsx +++ b/src/pages/storage/forms/DuplicateVolumeForm.tsx @@ -10,18 +10,15 @@ import { Select, } from "@canonical/react-components"; import * as Yup from "yup"; -import { useQuery } from "@tanstack/react-query"; -import { queryKeys } from "util/queryKeys"; import { duplicateStorageVolume } from "api/storage-pools"; import { useNavigate } from "react-router-dom"; import { useEventQueue } from "context/eventQueue"; import type { LxdStorageVolume } from "types/storage"; -import { useSupportedFeatures } from "context/useSupportedFeatures"; -import { loadCustomVolumes } from "context/loadCustomVolumes"; import StoragePoolSelector from "../StoragePoolSelector"; import { checkDuplicateName, getUniqueResourceName } from "util/helpers"; import ResourceLink from "components/ResourceLink"; import { useProjects } from "context/useProjects"; +import { useLoadCustomVolumes } from "context/useVolumes"; interface Props { volume: LxdStorageVolume; @@ -40,14 +37,11 @@ const DuplicateVolumeForm: FC = ({ volume, close }) => { const controllerState = useState(null); const navigate = useNavigate(); const eventQueue = useEventQueue(); - const { hasStorageVolumesAll } = useSupportedFeatures(); const { data: projects = [], isLoading: projectsLoading } = useProjects(); - const { data: volumes = [], isLoading: volumesLoading } = useQuery({ - queryKey: [queryKeys.customVolumes, volume.project], - queryFn: () => loadCustomVolumes(volume.project, hasStorageVolumesAll), - }); + const { data: volumes = [], isLoading: volumesLoading } = + useLoadCustomVolumes(volume.project); const notifySuccess = (volumeName: string, project: string, pool: string) => { const volumeUrl = `/ui/project/${project}/storage/pool/${pool}/volumes/custom/${volumeName}`; From 5d2523dbd3266e4a9cbcff50f4c93dbc4612ad95 Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Fri, 14 Feb 2025 11:39:37 +0200 Subject: [PATCH 03/12] feat: disable delete storage pool button when user has no permission Signed-off-by: Mason Hu --- .../storage/actions/DeleteStoragePoolBtn.tsx | 19 +++++++++++++++---- src/util/entitlements/storage-pools.tsx | 2 +- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/pages/storage/actions/DeleteStoragePoolBtn.tsx b/src/pages/storage/actions/DeleteStoragePoolBtn.tsx index ab53a14931..44aff94a9e 100644 --- a/src/pages/storage/actions/DeleteStoragePoolBtn.tsx +++ b/src/pages/storage/actions/DeleteStoragePoolBtn.tsx @@ -14,6 +14,7 @@ import type { LxdStoragePool } from "types/storage"; import { queryKeys } from "util/queryKeys"; import { useToastNotification } from "context/toastNotificationProvider"; import ResourceLabel from "components/ResourceLabel"; +import { useStoragePoolEntitlements } from "util/entitlements/storage-pools"; interface Props { pool: LxdStoragePool; @@ -32,6 +33,7 @@ const DeleteStoragePoolBtn: FC = ({ const toastNotify = useToastNotification(); const [isLoading, setLoading] = useState(false); const queryClient = useQueryClient(); + const { canDeletePool } = useStoragePoolEntitlements(); const handleDelete = () => { setLoading(true); @@ -54,8 +56,17 @@ const DeleteStoragePoolBtn: FC = ({ }); }; - const disabledReason = - (pool.used_by?.length ?? 0) > 0 ? "Storage pool is in use" : undefined; + const disabledReason = () => { + if (!canDeletePool(pool)) { + return "You do not have permission to delete this storage pool"; + } + + if (pool.used_by?.length ?? 0 > 0) { + return "Storage pool is in use"; + } + + return undefined; + }; return ( = ({ loading={isLoading} shiftClickEnabled showShiftClickHint - disabled={Boolean(disabledReason)} - onHoverText={disabledReason} + disabled={Boolean(disabledReason()) || !canDeletePool(pool)} + onHoverText={disabledReason()} > {(!isSmallScreen || !shouldExpand) && } {shouldExpand && Delete pool} diff --git a/src/util/entitlements/storage-pools.tsx b/src/util/entitlements/storage-pools.tsx index a055edc25a..d0b0670405 100644 --- a/src/util/entitlements/storage-pools.tsx +++ b/src/util/entitlements/storage-pools.tsx @@ -2,7 +2,7 @@ import { useAuth } from "context/auth"; import { hasEntitlement } from "./helpers"; import { LxdStoragePool } from "types/storage"; -export const useInstanceEntitlements = () => { +export const useStoragePoolEntitlements = () => { const { isFineGrained } = useAuth(); const canDeletePool = (pool: LxdStoragePool) => From 46a8d29d1824f9565f33292aa91478340c312888 Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Fri, 14 Feb 2025 11:52:48 +0200 Subject: [PATCH 04/12] feat: disable create storage pool button when user has no permission Signed-off-by: Mason Hu --- src/pages/storage/actions/CreateStoragePoolBtn.tsx | 8 ++++++++ src/util/entitlements/server.tsx | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/pages/storage/actions/CreateStoragePoolBtn.tsx b/src/pages/storage/actions/CreateStoragePoolBtn.tsx index b5b859c5e9..8652a8faa6 100644 --- a/src/pages/storage/actions/CreateStoragePoolBtn.tsx +++ b/src/pages/storage/actions/CreateStoragePoolBtn.tsx @@ -2,6 +2,7 @@ import { FC } from "react"; import { Button, Icon } from "@canonical/react-components"; import { useNavigate } from "react-router-dom"; import { useSmallScreen } from "context/useSmallScreen"; +import { useServerEntitlements } from "util/entitlements/server"; interface Props { project: string; @@ -11,13 +12,20 @@ interface Props { const CreateStoragePoolBtn: FC = ({ project, className }) => { const navigate = useNavigate(); const isSmallScreen = useSmallScreen(); + const { cancreateStoragePools } = useServerEntitlements(); return (