From 264504c22bf6e18ecb7cb7744cf892760f32601c Mon Sep 17 00:00:00 2001 From: Juntao Wang Date: Wed, 12 Jul 2023 10:43:41 -0400 Subject: [PATCH] Refactor BYON images table --- backend/src/routes/api/images/imageUtils.ts | 47 +- backend/src/types.ts | 34 +- .../dashboard/DashboardEmptyTableView.tsx | 36 ++ .../dashboard/DashboardSearchField.tsx | 69 +++ .../BYONImages/BYONImageStatusToggle.tsx | 48 ++ frontend/src/pages/BYONImages/BYONImages.tsx | 67 +-- .../src/pages/BYONImages/BYONImagesTable.scss | 29 -- .../src/pages/BYONImages/BYONImagesTable.tsx | 493 ++++-------------- .../pages/BYONImages/BYONImagesTableRow.tsx | 139 +++++ .../pages/BYONImages/DeleteBYONImageModal.tsx | 86 +-- .../src/pages/BYONImages/EmptyBYONImages.tsx | 30 ++ .../BYONImages/ImportBYONImageButton.tsx | 34 ++ ...mageModal.tsx => ManageBYONImageModal.tsx} | 200 ++++--- .../pages/BYONImages/UpdateImageModal.scss | 3 - .../src/pages/BYONImages/UpdateImageModal.tsx | 323 ------------ frontend/src/pages/BYONImages/tableData.tsx | 43 ++ frontend/src/services/imagesService.ts | 16 +- frontend/src/types.ts | 29 +- frontend/src/utilities/NavData.tsx | 2 +- frontend/src/utilities/useWatchBYONImages.ts | 11 + frontend/src/utilities/useWatchBYONImages.tsx | 54 -- 21 files changed, 714 insertions(+), 1079 deletions(-) create mode 100644 frontend/src/concepts/dashboard/DashboardEmptyTableView.tsx create mode 100644 frontend/src/concepts/dashboard/DashboardSearchField.tsx create mode 100644 frontend/src/pages/BYONImages/BYONImageStatusToggle.tsx delete mode 100644 frontend/src/pages/BYONImages/BYONImagesTable.scss create mode 100644 frontend/src/pages/BYONImages/BYONImagesTableRow.tsx create mode 100644 frontend/src/pages/BYONImages/EmptyBYONImages.tsx create mode 100644 frontend/src/pages/BYONImages/ImportBYONImageButton.tsx rename frontend/src/pages/BYONImages/{ImportImageModal.tsx => ManageBYONImageModal.tsx} (71%) delete mode 100644 frontend/src/pages/BYONImages/UpdateImageModal.scss delete mode 100644 frontend/src/pages/BYONImages/UpdateImageModal.tsx create mode 100644 frontend/src/pages/BYONImages/tableData.tsx create mode 100644 frontend/src/utilities/useWatchBYONImages.ts delete mode 100644 frontend/src/utilities/useWatchBYONImages.tsx diff --git a/backend/src/routes/api/images/imageUtils.ts b/backend/src/routes/api/images/imageUtils.ts index 6cce674e07..72736d48dd 100644 --- a/backend/src/routes/api/images/imageUtils.ts +++ b/backend/src/routes/api/images/imageUtils.ts @@ -7,8 +7,6 @@ import { ImageStream, TagContent, KubeFastifyInstance, - BYONImageCreateRequest, - BYONImageUpdateRequest, BYONImagePackage, BYONImage, } from '../../../types'; @@ -189,24 +187,19 @@ const packagesToString = (packages: BYONImagePackage[]): string => { return '[]'; }; const mapImageStreamToBYONImage = (is: ImageStream): BYONImage => ({ - id: is.metadata.name, - name: is.metadata.annotations['opendatahub.io/notebook-image-name'], - description: is.metadata.annotations['opendatahub.io/notebook-image-desc'], + id: is.metadata.uid, + name: is.metadata.name, + display_name: is.metadata.annotations['opendatahub.io/notebook-image-name'] || is.metadata.name, + description: is.metadata.annotations['opendatahub.io/notebook-image-desc'] || '', visible: is.metadata.labels['opendatahub.io/notebook-image'] === 'true', error: getBYONImageErrorMessage(is), - packages: - is.spec.tags && - (JSON.parse( - is.spec.tags[0].annotations['opendatahub.io/notebook-python-dependencies'], - ) as BYONImagePackage[]), - software: - is.spec.tags && - (JSON.parse( - is.spec.tags[0].annotations['opendatahub.io/notebook-software'], - ) as BYONImagePackage[]), - uploaded: is.metadata.creationTimestamp, + packages: JSON.parse( + is.spec.tags?.[0].annotations['opendatahub.io/notebook-python-dependencies'] || '[]', + ), + software: JSON.parse(is.spec.tags?.[0].annotations['opendatahub.io/notebook-software'] || '[]'), + imported_time: is.metadata.creationTimestamp, url: is.metadata.annotations['opendatahub.io/notebook-image-url'], - user: is.metadata.annotations['opendatahub.io/notebook-image-creator'], + provider: is.metadata.annotations['opendatahub.io/notebook-image-creator'], }); export const postImage = async ( @@ -215,7 +208,7 @@ export const postImage = async ( ): Promise<{ success: boolean; error: string }> => { const customObjectsApi = fastify.kube.customObjectsApi; const namespace = fastify.kube.namespace; - const body = request.body as BYONImageCreateRequest; + const body = request.body as BYONImage; const fullUrl = body.url; const matchArray = fullUrl.match(imageUrlRegex); // check if the host is valid @@ -234,7 +227,7 @@ export const postImage = async ( if (validName.length > 0) { fastify.log.error('Duplicate name unable to add notebook image'); - return { success: false, error: 'Unable to add notebook image: ' + body.name }; + return { success: false, error: 'Unable to add notebook image: ' + body.display_name }; } const payload: ImageStream = { @@ -242,10 +235,10 @@ export const postImage = async ( apiVersion: 'image.openshift.io/v1', metadata: { annotations: { - 'opendatahub.io/notebook-image-desc': body.description ? body.description : '', - 'opendatahub.io/notebook-image-name': body.name, + 'opendatahub.io/notebook-image-desc': body.description || '', + 'opendatahub.io/notebook-image-name': body.display_name, 'opendatahub.io/notebook-image-url': fullUrl, - 'opendatahub.io/notebook-image-creator': body.user, + 'opendatahub.io/notebook-image-creator': body.provider, }, name: `byon-${Date.now()}`, namespace: namespace, @@ -325,7 +318,7 @@ export const updateImage = async ( const customObjectsApi = fastify.kube.customObjectsApi; const namespace = fastify.kube.namespace; const params = request.params as { image: string }; - const body = request.body as BYONImageUpdateRequest; + const body = request.body as BYONImage; const labels = { 'app.kubernetes.io/created-by': 'byon', 'opendatahub.io/notebook-image': 'true', @@ -334,8 +327,8 @@ export const updateImage = async ( const imageStreams = await getImageStreams(fastify, labels); const validName = imageStreams.filter( (is) => - is.metadata.annotations['opendatahub.io/notebook-image-name'] === body.name && - is.metadata.name !== body.id, + is.metadata.annotations['opendatahub.io/notebook-image-name'] === body.display_name && + is.metadata.name !== body.name, ); if (validName.length > 0) { @@ -375,8 +368,8 @@ export const updateImage = async ( imageStream.metadata.labels['opendatahub.io/notebook-image'] = 'false'; } } - if (body.name) { - imageStream.metadata.annotations['opendatahub.io/notebook-image-name'] = body.name; + if (body.display_name) { + imageStream.metadata.annotations['opendatahub.io/notebook-image-name'] = body.display_name; } if (body.description !== undefined) { diff --git a/backend/src/types.ts b/backend/src/types.ts index 3c85ad8afa..254c0db880 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -466,20 +466,16 @@ export type ODHSegmentKey = { export type BYONImage = { id: string; - user?: string; - uploaded?: Date; - error?: string; -} & BYONImageCreateRequest & - BYONImageUpdateRequest; - -export type BYONImageCreateRequest = { + provider: string; + imported_time: string; + error: string; name: string; url: string; - description?: string; - // FIXME: This shouldn't be a user defined value consumed from the request payload but should be a controlled value from an authentication middleware. - user: string; - software?: BYONImagePackage[]; - packages?: BYONImagePackage[]; + display_name: string; + description: string; + visible: boolean; + software: BYONImagePackage[]; + packages: BYONImagePackage[]; }; export type ImageTag = { @@ -487,15 +483,6 @@ export type ImageTag = { tag: ImageTagInfo | undefined; }; -export type BYONImageUpdateRequest = { - id: string; - name?: string; - description?: string; - visible?: boolean; - software?: BYONImagePackage[]; - packages?: BYONImagePackage[]; -}; - export type BYONImagePackage = { name: string; version: string; @@ -546,6 +533,7 @@ export type ImageStream = { namespace: string; labels?: { [key: string]: string }; annotations?: { [key: string]: string }; + creationTimestamp?: string; }; spec: { lookupPolicy?: { @@ -826,7 +814,6 @@ export type TemplateParameter = { required: boolean; }; - export type Template = K8sResourceCommon & { metadata: { annotations?: Partial<{ @@ -870,7 +857,6 @@ export type ContainerResources = { }; }; - export type ServingRuntime = K8sResourceCommon & { metadata: { annotations?: DisplayNameAnnotations & ServingRuntimeAnnotations; @@ -893,4 +879,4 @@ export type ServingRuntime = K8sResourceCommon & { supportedModelFormats: SupportedModelFormats[]; replicas: number; }; -}; \ No newline at end of file +}; diff --git a/frontend/src/concepts/dashboard/DashboardEmptyTableView.tsx b/frontend/src/concepts/dashboard/DashboardEmptyTableView.tsx new file mode 100644 index 0000000000..5d0480fc38 --- /dev/null +++ b/frontend/src/concepts/dashboard/DashboardEmptyTableView.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { + Bullseye, + Button, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStatePrimary, + Title, +} from '@patternfly/react-core'; +import { SearchIcon } from '@patternfly/react-icons'; + +type DashboardEmptyTableViewProps = { + onClearFilters: () => void; +}; + +const DashboardEmptyTableView: React.FC = ({ onClearFilters }) => ( + + + + + No results found + + + No results match the filter criteria. Clear all filters and try again. + + + + + + +); + +export default DashboardEmptyTableView; diff --git a/frontend/src/concepts/dashboard/DashboardSearchField.tsx b/frontend/src/concepts/dashboard/DashboardSearchField.tsx new file mode 100644 index 0000000000..bbc8fcd5e3 --- /dev/null +++ b/frontend/src/concepts/dashboard/DashboardSearchField.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { InputGroup, SearchInput, Select, SelectOption } from '@patternfly/react-core'; + +// List all the possible search fields here +export enum SearchType { + NAME = 'Name', + DESCRIPTION = 'Description', + USER = 'User', + PROJECT = 'Project', + METRIC = 'Metric', + PROTECTED_ATTRIBUTE = 'Protected attribute', + PRIVILEGED_VALUE = 'Privileged value', + UNPRIVILEGED_VALUE = 'Unprivileged value', + OUTPUT = 'Output', + OUTPUT_VALUE = 'Output value', + PROVIDER = 'Provider', +} + +type DashboardSearchFieldProps = { + types: string[]; + searchType: SearchType; + onSearchTypeChange: (searchType: SearchType) => void; + searchValue: string; + onSearchValueChange: (searchValue: string) => void; +}; + +const DashboardSearchField: React.FC = ({ + types, + searchValue, + searchType, + onSearchValueChange, + onSearchTypeChange, +}) => { + const [typeOpen, setTypeOpen] = React.useState(false); + + return ( + + + { + onSearchValueChange(newSearch); + }} + onClear={() => onSearchValueChange('')} + style={{ minWidth: '200px' }} + /> + + ); +}; + +export default DashboardSearchField; diff --git a/frontend/src/pages/BYONImages/BYONImageStatusToggle.tsx b/frontend/src/pages/BYONImages/BYONImageStatusToggle.tsx new file mode 100644 index 0000000000..e7d84fdf9d --- /dev/null +++ b/frontend/src/pages/BYONImages/BYONImageStatusToggle.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { Switch } from '@patternfly/react-core'; +import { BYONImage } from '~/types'; +import { updateBYONImage } from '~/services/imagesService'; +import useNotification from '~/utilities/useNotification'; + +type BYONImageStatusToggleProps = { + image: BYONImage; +}; + +const BYONImageStatusToggle: React.FC = ({ image }) => { + const [isLoading, setLoading] = React.useState(false); + const [isEnabled, setEnabled] = React.useState(image.visible); + const notification = useNotification(); + const handleChange = (checked: boolean) => { + setLoading(true); + updateBYONImage({ + name: image.name, + visible: checked, + packages: image.packages, + }) + .then(() => { + setEnabled(checked); + }) + .catch((e) => { + notification.error( + `Error ${checked ? 'enable' : 'disable'} the serving runtime`, + e.message, + ); + setEnabled(!checked); + }) + .finally(() => { + setLoading(false); + }); + }; + + return ( + + ); +}; + +export default BYONImageStatusToggle; diff --git a/frontend/src/pages/BYONImages/BYONImages.tsx b/frontend/src/pages/BYONImages/BYONImages.tsx index 5b8831f003..650550b929 100644 --- a/frontend/src/pages/BYONImages/BYONImages.tsx +++ b/frontend/src/pages/BYONImages/BYONImages.tsx @@ -1,77 +1,24 @@ import * as React from 'react'; -import { - Button, - ButtonVariant, - Flex, - FlexItem, - EmptyState, - EmptyStateIcon, - EmptyStateVariant, - EmptyStateBody, - PageSection, - PageSectionVariants, - Title, -} from '@patternfly/react-core'; -import { PlusCircleIcon } from '@patternfly/react-icons'; import ApplicationsPage from '~/pages/ApplicationsPage'; import { useWatchBYONImages } from '~/utilities/useWatchBYONImages'; -import { ImportImageModal } from './ImportImageModal'; import { BYONImagesTable } from './BYONImagesTable'; - -const description = `Import, delete, and modify notebook images.`; +import EmptyBYONImages from './EmptyBYONImages'; const BYONImages: React.FC = () => { - const [importImageModalVisible, setImportImageModalVisible] = React.useState(false); - - const { images, loaded, loadError, forceUpdate } = useWatchBYONImages(); - const isEmpty = !images || images.length === 0; - - const noImagesPageSection = ( - - - - - No custom notebook images found. - - To get started import a custom notebook image. - - - { - setImportImageModalVisible(false); - }} - onImportHandler={forceUpdate} - /> - - ); + const [images, loaded, loadError, refresh] = useWatchBYONImages(); return ( } + provideChildrenPadding > - - - - {' '} - - - - + ); }; diff --git a/frontend/src/pages/BYONImages/BYONImagesTable.scss b/frontend/src/pages/BYONImages/BYONImagesTable.scss deleted file mode 100644 index 461fa99341..0000000000 --- a/frontend/src/pages/BYONImages/BYONImagesTable.scss +++ /dev/null @@ -1,29 +0,0 @@ -.filter-select { - margin-right: 0px; -} -.filter-search { - margin-right: var(--pf-global--spacer--md); -} -.included-packages { - margin-left: var(--pf-global--spacer--2xl); -} -.included-packages-font { - color: var(--pf-global--Color--200); -} -.phase-success { - color: var(--pf-global--success-color--100); -} -.phase-failed { - color: var(--pf-global--warning-color--100); -} -.phase-failed-cursor{ - cursor: pointer; -} -.enable-switch { - size: 75% -} - -.empty-table { - margin-left: var(--pf-global--spacer--2xl); - margin-right: var(--pf-global--spacer--2xl); -} \ No newline at end of file diff --git a/frontend/src/pages/BYONImages/BYONImagesTable.tsx b/frontend/src/pages/BYONImages/BYONImagesTable.tsx index 5eaa56bec7..e288c222a7 100644 --- a/frontend/src/pages/BYONImages/BYONImagesTable.tsx +++ b/frontend/src/pages/BYONImages/BYONImagesTable.tsx @@ -1,424 +1,117 @@ import React from 'react'; -import { - Button, - Bullseye, - EmptyState, - EmptyStateBody, - EmptyStateIcon, - EmptyStateVariant, - Flex, - FlexItem, - Select, - SelectOption, - SelectVariant, - SearchInput, - Switch, - Title, - Toolbar, - ToolbarContent, - ToolbarItem, -} from '@patternfly/react-core'; -import { - ActionsColumn, - TableComposable, - Thead, - Tr, - Th, - ThProps, - Tbody, - Td, - ExpandableRowContent, - IAction, -} from '@patternfly/react-table'; -import { CubesIcon, SearchIcon } from '@patternfly/react-icons'; +import { ToolbarItem } from '@patternfly/react-core'; import { BYONImage } from '~/types'; -import { relativeTime } from '~/utilities/time'; -import { updateBYONImage } from '~/services/imagesService'; -import ImageErrorStatus from '~/pages/BYONImages/ImageErrorStatus'; -import { ImportImageModal } from './ImportImageModal'; -import { DeleteImageModal } from './DeleteBYONImageModal'; -import { UpdateImageModal } from './UpdateImageModal'; - -import './BYONImagesTable.scss'; +import Table from '~/components/table/Table'; +import DashboardSearchField, { SearchType } from '~/concepts/dashboard/DashboardSearchField'; +import DashboardEmptyTableView from '~/concepts/dashboard/DashboardEmptyTableView'; +import ManageBYONImageModal from './ManageBYONImageModal'; +import DeleteBYONImageModal from './DeleteBYONImageModal'; +import { columns } from './tableData'; +import BYONImagesTableRow from './BYONImagesTableRow'; +import ImportBYONImageButton from './ImportBYONImageButton'; export type BYONImagesTableProps = { images: BYONImage[]; - forceUpdate: () => void; -}; - -type BYONImageEnabled = { - id: string; - visible?: boolean; -}; - -type BYONImageTableFilterOptions = 'user' | 'name' | 'description' | 'phase' | 'user' | 'uploaded'; -type BYONImageTableFilter = { - filter: string; - option: BYONImageTableFilterOptions; - count: number; + refresh: () => void; }; -export const BYONImagesTable: React.FC = ({ images, forceUpdate }) => { - const rowActions = (image: BYONImage): IAction[] => [ - { - title: 'Edit', - id: `${image.name}-edit-button`, - onClick: () => { - setcurrentImage(image); - setUpdateImageModalVisible(true); - }, - }, - { - isSeparator: true, - }, - { - title: 'Delete', - id: `${image.name}-delete-button`, - onClick: () => { - setcurrentImage(image); - setDeleteImageModalVisible(true); - }, - }, - ]; - - React.useEffect(() => { - setBYONImageVisible(images.map((image) => ({ id: image.id, visible: image.visible }))); - }, [images]); - - const [currentImage, setcurrentImage] = React.useState(images[0]); - const [deleteImageModalVisible, setDeleteImageModalVisible] = React.useState(false); - const [importImageModalVisible, setImportImageModalVisible] = React.useState(false); - const [updateImageModalVisible, setUpdateImageModalVisible] = React.useState(false); - - const [activeSortIndex, setActiveSortIndex] = React.useState(0); - const [activeSortDirection, setActiveSortDirection] = React.useState<'asc' | 'desc' | undefined>( - 'asc', - ); - - const getFilterCount = (value: string, option): number => { - let total = 0; - images.forEach((image) => { - (image[option] as string).includes(value) ? total++ : null; - }); - return total; - }; - - const getSortableRowValues = (nb: BYONImage): string[] => { - const { name, description = '', visible = false, user = '', uploaded = '' } = nb; - return [name, description, visible.toString(), user, uploaded.toString()]; - }; - - const sortedImages = React.useMemo(() => { - if (activeSortIndex !== undefined) { - return [...images].sort((a, b) => { - const aValue = getSortableRowValues(a)[activeSortIndex]; - const bValue = getSortableRowValues(b)[activeSortIndex]; - - if (activeSortDirection === 'asc') { - return (aValue as string).localeCompare(bValue as string); - } - return (bValue as string).localeCompare(aValue as string); - }); +export const BYONImagesTable: React.FC = ({ images, refresh }) => { + const [searchType, setSearchType] = React.useState(SearchType.NAME); + const [search, setSearch] = React.useState(''); + const filteredImages = images.filter((image) => { + if (!search) { + return true; } - return [...images]; - }, [activeSortDirection, activeSortIndex, images]); - const getSortParams = (columnIndex: number): ThProps['sort'] => ({ - sortBy: { - index: activeSortIndex, - direction: activeSortDirection, - defaultDirection: 'asc', - }, - onSort: (_event, index, direction) => { - setActiveSortIndex(index); - setActiveSortDirection(direction); - }, - columnIndex, + switch (searchType) { + case SearchType.NAME: + return image.display_name.toLowerCase().includes(search.toLowerCase()); + case SearchType.DESCRIPTION: + return image.description.toLowerCase().includes(search.toLowerCase()); + case SearchType.PROVIDER: + return image.provider.toLowerCase().includes(search.toLowerCase()); + default: + return true; + } }); - const columnNames = { - name: 'Name', - description: 'Description', - user: 'User', - uploaded: 'Uploaded', + const resetFilters = () => { + setSearch(''); }; - const currentTimeStamp: number = Date.now(); - - const [expandedBYONImageIDs, setExpandedBYONImageIDs] = React.useState([]); - const setBYONImageExpanded = (image: BYONImage, isExpanding = true) => { - setExpandedBYONImageIDs((prevExpanded) => { - const otherExpandedRepoNames = prevExpanded.filter((r) => r !== image.id); - return isExpanding ? [...otherExpandedRepoNames, image.id] : otherExpandedRepoNames; - }); - }; - const isBYONImageExpanded = (image: BYONImage) => expandedBYONImageIDs.includes(image.id); - const [BYONImageVisible, setBYONImageVisible] = React.useState( - images.map((image) => ({ id: image.id, visible: image.visible })), + const searchTypes = React.useMemo( + () => + Object.keys(SearchType).filter( + (key) => + SearchType[key] === SearchType.NAME || + SearchType[key] === SearchType.DESCRIPTION || + SearchType[key] === SearchType.PROVIDER, + ), + [], ); - const selectOptions = [ - - Name - , - - Description - , - - User - , - - Uploaded - , - ]; - const [tableFilter, setTableFilter] = React.useState({ - filter: '', - option: 'name', - count: images.length, - }); - const [selected, setSelected] = React.useState('name'); - const [tableSelectIsOpen, setTableSelectIsOpen] = React.useState(false); - - const items = ( - - - - - - { - const newCount = getFilterCount(value, tableFilter.option); - setTableFilter({ - filter: value, - option: tableFilter.option, - count: newCount, - }); - }} - onClear={() => { - setTableFilter({ - filter: '', - option: tableFilter.option, - count: images.length, - }); - }} - /> - - - - - - ); + const [editImage, setEditImage] = React.useState(); + const [deleteImage, setDeleteImage] = React.useState(); - const applyTableFilter = (image: BYONImage): boolean => { - if ( - tableFilter.filter !== '' && - image[tableFilter.option] && - tableFilter.option !== 'uploaded' - ) { - const BYONImageValue: string = image[tableFilter.option] as string; - return !BYONImageValue.includes(tableFilter.filter); - } - if ( - tableFilter.filter !== '' && - image[tableFilter.option] && - tableFilter.option === 'uploaded' - ) { - const BYONImageValue: string = relativeTime( - currentTimeStamp, - new Date(image.uploaded as Date).getTime(), - ); - return !BYONImageValue.includes(tableFilter.filter); - } - return false; - }; return ( - - { - setDeleteImageModalVisible(false); - }} + <> + } + disableRowRenderSupport + rowRenderer={(image, index) => ( + setEditImage(i)} + onDeleteImage={(i) => setDeleteImage(i)} + /> + )} + toolbarContent={ + <> + + { + setSearchType(searchType); + }} + onSearchValueChange={(searchValue) => { + setSearch(searchValue); + }} + /> + + + + + + } /> - { - setImportImageModalVisible(false); + { + if (deleted) { + refresh(); + } + setDeleteImage(undefined); }} - onImportHandler={forceUpdate} /> - { - setUpdateImageModalVisible(false); + { + if (updated) { + refresh(); + } + setEditImage(undefined); }} + existingImage={editImage} /> - - {items} - - - - - - - - - - - - {tableFilter.count > 0 ? ( - sortedImages.map((image, rowIndex) => { - const packages: React.ReactNode[] = []; - image.packages?.forEach((nbpackage) => { - packages.push(

{`${nbpackage.name} ${nbpackage.version}`}

); - }); - return ( - - - - - - - - - - - - - - ); - }) - ) : ( - - - - - - )} - - + ); }; diff --git a/frontend/src/pages/BYONImages/BYONImagesTableRow.tsx b/frontend/src/pages/BYONImages/BYONImagesTableRow.tsx new file mode 100644 index 0000000000..af833ac01e --- /dev/null +++ b/frontend/src/pages/BYONImages/BYONImagesTableRow.tsx @@ -0,0 +1,139 @@ +import * as React from 'react'; +import { ActionsColumn, ExpandableRowContent, Tbody, Td, Tr } from '@patternfly/react-table'; +import { + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Flex, + FlexItem, + Timestamp, + TimestampTooltipVariant, +} from '@patternfly/react-core'; +import { BYONImage } from '~/types'; +import { relativeTime } from '~/utilities/time'; +import ImageErrorStatus from './ImageErrorStatus'; +import BYONImageStatusToggle from './BYONImageStatusToggle'; + +type BYONImagesTableRowProps = { + obj: BYONImage; + rowIndex: number; + onEditImage: (obj: BYONImage) => void; + onDeleteImage: (obj: BYONImage) => void; +}; + +const BYONImagesTableRow: React.FC = ({ + obj, + rowIndex, + onEditImage, + onDeleteImage, +}) => { + const [isExpanded, setExpanded] = React.useState(false); + const columnModifier = + obj.software.length > 0 && obj.packages.length > 0 + ? '3Col' + : obj.software.length === 0 && obj.packages.length === 0 + ? '1Col' + : '2Col'; + + return ( + + + + + + + + + + + + + + ); +}; + +export default BYONImagesTableRow; diff --git a/frontend/src/pages/BYONImages/DeleteBYONImageModal.tsx b/frontend/src/pages/BYONImages/DeleteBYONImageModal.tsx index 7e04735437..03e726932b 100644 --- a/frontend/src/pages/BYONImages/DeleteBYONImageModal.tsx +++ b/frontend/src/pages/BYONImages/DeleteBYONImageModal.tsx @@ -1,47 +1,51 @@ import React from 'react'; -import { Button, Modal, ModalVariant } from '@patternfly/react-core'; import { deleteBYONImage } from '~/services/imagesService'; import { BYONImage } from '~/types'; -export type ImportImageModalProps = { - isOpen: boolean; - image: BYONImage; - onDeleteHandler: () => void; - onCloseHandler: () => void; +import DeleteModal from '~/pages/projects/components/DeleteModal'; + +export type DeleteBYONImageModalProps = { + image?: BYONImage; + onClose: (deleted: boolean) => void; }; -export const DeleteImageModal: React.FC = ({ - isOpen, - image, - onDeleteHandler, - onCloseHandler, -}) => ( - { - if (image) { - deleteBYONImage(image).then(() => { - onDeleteHandler(); - onCloseHandler(); + +export const DeleteBYONImageModal: React.FC = ({ image, onClose }) => { + const [isDeleting, setIsDeleting] = React.useState(false); + const [error, setError] = React.useState(); + + const onBeforeClose = (deleted: boolean) => { + onClose(deleted); + setIsDeleting(false); + setError(undefined); + }; + + const deleteName = image?.display_name || 'this notebook image'; + + return ( + onBeforeClose(false)} + submitButtonLabel="Delete notebook image" + onDelete={() => { + if (image) { + setIsDeleting(true); + deleteBYONImage(image) + .then(() => { + onBeforeClose(true); + }) + .catch((e) => { + setError(e); + setIsDeleting(false); }); - } - }} - > - Delete - , - , - ]} - > - Do you wish to permanently delete {image?.name}? - -); + } + }} + deleting={isDeleting} + error={error} + deleteName={deleteName} + > + This action cannot be undone. + + ); +}; -export default DeleteImageModal; +export default DeleteBYONImageModal; diff --git a/frontend/src/pages/BYONImages/EmptyBYONImages.tsx b/frontend/src/pages/BYONImages/EmptyBYONImages.tsx new file mode 100644 index 0000000000..376e6b2ca0 --- /dev/null +++ b/frontend/src/pages/BYONImages/EmptyBYONImages.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStateVariant, + PageSection, + Title, +} from '@patternfly/react-core'; +import { PlusCircleIcon } from '@patternfly/react-icons'; +import ImportBYONImageButton from './ImportBYONImageButton'; + +type EmptyBYONImagesProps = { + refresh: () => void; +}; + +const EmptyBYONImages: React.FC = ({ refresh }) => ( + + + + + No custom notebook images found. + + To get started import a custom notebook image. + + + +); + +export default EmptyBYONImages; diff --git a/frontend/src/pages/BYONImages/ImportBYONImageButton.tsx b/frontend/src/pages/BYONImages/ImportBYONImageButton.tsx new file mode 100644 index 0000000000..cc70f41472 --- /dev/null +++ b/frontend/src/pages/BYONImages/ImportBYONImageButton.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { Button } from '@patternfly/react-core'; +import ManageBYONImageModal from './ManageBYONImageModal'; + +type ImportBYONImageButtonProps = { + refresh: () => void; +}; + +const ImportBYONImageButton: React.FC = ({ refresh }) => { + const [isOpen, setOpen] = React.useState(false); + return ( + <> + + { + if (imported) { + refresh(); + } + setOpen(false); + }} + /> + + ); +}; + +export default ImportBYONImageButton; diff --git a/frontend/src/pages/BYONImages/ImportImageModal.tsx b/frontend/src/pages/BYONImages/ManageBYONImageModal.tsx similarity index 71% rename from frontend/src/pages/BYONImages/ImportImageModal.tsx rename to frontend/src/pages/BYONImages/ManageBYONImageModal.tsx index ec61a75737..65350c1841 100644 --- a/frontend/src/pages/BYONImages/ImportImageModal.tsx +++ b/frontend/src/pages/BYONImages/ManageBYONImageModal.tsx @@ -16,91 +16,128 @@ import { TabTitleText, } from '@patternfly/react-core'; import { Caption, TableComposable, Tbody, Thead, Th, Tr } from '@patternfly/react-table'; -import { CubesIcon, ExclamationCircleIcon, PlusCircleIcon } from '@patternfly/react-icons'; -import { importBYONImage } from '~/services/imagesService'; -import { ResponseStatus, BYONImagePackage } from '~/types'; +import { CubesIcon, PlusCircleIcon } from '@patternfly/react-icons'; +import { importBYONImage, updateBYONImage } from '~/services/imagesService'; +import { ResponseStatus, BYONImagePackage, BYONImage } from '~/types'; import { addNotification } from '~/redux/actions/actions'; import { useAppDispatch, useAppSelector } from '~/redux/hooks'; import { EditStepTableRow } from './EditStepTableRow'; import './ImportImageModal.scss'; -export type ImportImageModalProps = { +export type ManageBYONImageModalProps = { + existingImage?: BYONImage; isOpen: boolean; - onCloseHandler: () => void; - onImportHandler(); + onClose: (submitted: boolean) => void; }; -export const ImportImageModal: React.FC = ({ + +export const ManageBYONImageModal: React.FC = ({ + existingImage, isOpen, - onImportHandler, - onCloseHandler, + onClose, }) => { + const [isProgress, setIsProgress] = React.useState(false); const [repository, setRepository] = React.useState(''); - const [name, setName] = React.useState(''); + const [displayName, setDisplayName] = React.useState(''); const [description, setDescription] = React.useState(''); const [software, setSoftware] = React.useState([]); const [packages, setPackages] = React.useState([]); const [activeTabKey, setActiveTabKey] = React.useState(0); - const [validName, setValidName] = React.useState(true); - const [validRepo, setValidRepo] = React.useState(true); const userName = useAppSelector((state) => state.user || ''); const dispatch = useAppDispatch(); + const isDisabled = isProgress || displayName === '' || repository === ''; + React.useEffect(() => { - if (isOpen === true) { - setName(''); - setDescription(''); - setPackages([]); - setSoftware([]); - setValidName(true); - setValidRepo(true); + if (existingImage) { + setRepository(existingImage.url); + setDisplayName(existingImage.display_name); + setDescription(existingImage.description); + setPackages(existingImage.packages); + setSoftware(existingImage.software); + } + }, [existingImage]); + + const onBeforeClose = (submitted: boolean) => { + onClose(submitted); + setIsProgress(false); + setRepository(''); + setDisplayName(''); + setDescription(''); + setSoftware([]); + setPackages([]); + }; + + const submit = () => { + if (existingImage) { + updateBYONImage({ + name: existingImage.name, + // eslint-disable-next-line camelcase + display_name: displayName, + description: description, + packages: packages, + software: software, + }).then((value) => { + if (value.success === false) { + dispatch( + addNotification({ + status: 'danger', + title: 'Error', + message: `Unable to update image ${name}`, + timestamp: new Date(), + }), + ); + } + onBeforeClose(true); + }); + } else { + importBYONImage({ + // eslint-disable-next-line camelcase + display_name: displayName, + url: repository, + description: description, + provider: userName, + software: software, + packages: packages, + }).then((value: ResponseStatus) => { + if (value.success === false) { + dispatch( + addNotification({ + status: 'danger', + title: `Unable to add notebook image ${name}`, + message: value.error, + timestamp: new Date(), + }), + ); + } + onBeforeClose(true); + }); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen]); + }; return ( onBeforeClose(false)} + showClose actions={[ , - , ]} @@ -108,47 +145,40 @@ export const ImportImageModal: React.FC = ({
{ e.preventDefault(); + submit(); }} > - } - validated={validRepo ? undefined : 'error'} - > - { - setRepository(value); - }} - /> - - } - validated={validName ? undefined : 'error'} - > + fieldId="byon-image-repository-input" + helperText="Repo where notebook images are stored." + > + { + setRepository(value); + }} + /> + + )} + { - setName(value); + setDisplayName(value); }} /> @@ -340,4 +370,4 @@ export const ImportImageModal: React.FC = ({ ); }; -export default ImportImageModal; +export default ManageBYONImageModal; diff --git a/frontend/src/pages/BYONImages/UpdateImageModal.scss b/frontend/src/pages/BYONImages/UpdateImageModal.scss deleted file mode 100644 index f70d594109..0000000000 --- a/frontend/src/pages/BYONImages/UpdateImageModal.scss +++ /dev/null @@ -1,3 +0,0 @@ -.empty-button { - margin-top: var(--pf-global--spacer--lg); -} \ No newline at end of file diff --git a/frontend/src/pages/BYONImages/UpdateImageModal.tsx b/frontend/src/pages/BYONImages/UpdateImageModal.tsx deleted file mode 100644 index 1d91f23630..0000000000 --- a/frontend/src/pages/BYONImages/UpdateImageModal.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import React from 'react'; -import { - Button, - EmptyState, - EmptyStateBody, - EmptyStateIcon, - EmptyStateVariant, - Form, - FormGroup, - Tab, - Tabs, - TabTitleText, - TextInput, - Title, - Modal, - ModalVariant, -} from '@patternfly/react-core'; -import { Caption, TableComposable, Tbody, Thead, Th, Tr } from '@patternfly/react-table'; -import { CubesIcon, ExclamationCircleIcon, PlusCircleIcon } from '@patternfly/react-icons'; -import { updateBYONImage } from '~/services/imagesService'; -import { BYONImage, BYONImagePackage } from '~/types'; -import { addNotification } from '~/redux/actions/actions'; -import { useAppDispatch } from '~/redux/hooks'; -import { EditStepTableRow } from './EditStepTableRow'; - -import './UpdateImageModal.scss'; - -export type UpdateImageModalProps = { - isOpen: boolean; - image: BYONImage; - onCloseHandler: () => void; - onUpdateHandler(); -}; -export const UpdateImageModal: React.FC = ({ - isOpen, - image, - onUpdateHandler, - onCloseHandler, -}) => { - const [name, setName] = React.useState(image.name); - const [description, setDescription] = React.useState( - image.description != undefined ? image.description : '', - ); - const [packages, setPackages] = React.useState( - image.packages != undefined ? image.packages : [], - ); - const [software, setSoftware] = React.useState( - image.software != undefined ? image.software : [], - ); - const [activeTabKey, setActiveTabKey] = React.useState(0); - const [validName, setValidName] = React.useState(true); - const dispatch = useAppDispatch(); - - React.useEffect(() => { - if (isOpen === true) { - setName(image.name); - setDescription(image.description != undefined ? image.description : ''); - setPackages(image.packages != undefined ? image.packages : []); - setSoftware(image.software != undefined ? image.software : []); - setValidName(true); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen]); - - return ( - { - if (name.length > 0) { - updateBYONImage({ - id: image.id, - name: name, - description: description, - packages: packages, - software: software, - }).then((value) => { - if (value.success === false) { - dispatch( - addNotification({ - status: 'danger', - title: 'Error', - message: `Unable to update image ${name}`, - timestamp: new Date(), - }), - ); - } - onUpdateHandler(); - onCloseHandler(); - }); - } else { - name.length > 0 ? setValidName(true) : setValidName(false); - } - }} - > - Save Changes - , - , - ]} - > - { - e.preventDefault(); - }} - > - } - validated={validName ? undefined : 'error'} - > - { - setName(value); - }} - /> - - - { - setDescription(value); - }} - /> - - - { - setActiveTabKey(indexKey as number); - }} - > - Software}> - {software.length > 0 ? ( - <> - -
- - - - - - - - {software.map((value, currentIndex) => ( - { - const updatedPackages = [...software]; - updatedPackages[currentIndex] = values; - setSoftware(updatedPackages); - }} - onDeleteHandler={() => { - setSoftware(software.filter((_value, index) => index !== currentIndex)); - }} - /> - ))} - - - - - ) : ( - - - - No software added - - - Add software to be advertised with your notebook image. Making changes here - won’t affect the contents of the image.{' '} - - - - )} - - Packages}> - {packages.length > 0 ? ( - <> - - - - - - - - - - {packages.map((value, currentIndex) => ( - { - const updatedPackages = [...packages]; - updatedPackages[currentIndex] = values; - setPackages(updatedPackages); - }} - onDeleteHandler={() => { - setPackages(packages.filter((_value, index) => index !== currentIndex)); - }} - /> - ))} - - - - - ) : ( - - - - No packages added - - - Add packages to be advertised with your notebook image. Making changes here - won’t affect the contents of the image.{' '} - - - - )} - - - - - - ); -}; - -export default UpdateImageModal; diff --git a/frontend/src/pages/BYONImages/tableData.tsx b/frontend/src/pages/BYONImages/tableData.tsx new file mode 100644 index 0000000000..0168368a91 --- /dev/null +++ b/frontend/src/pages/BYONImages/tableData.tsx @@ -0,0 +1,43 @@ +import { SortableData } from '~/components/table/useTableColumnSort'; +import { BYONImage } from '~/types'; + +export const columns: SortableData[] = [ + { + field: 'expand', + label: '', + sortable: false, + }, + { + field: 'name', + label: 'Name', + sortable: (a, b) => a.name.localeCompare(b.name), + }, + { + field: 'description', + label: 'Description', + sortable: (a, b) => a.description.localeCompare(b.description), + }, + { + field: 'enable', + label: 'Enable', + sortable: false, + info: { + tooltip: 'Enabled images are selectable when creating workbenches.', + }, + }, + { + field: 'provider', + label: 'Provider', + sortable: (a, b) => a.provider.localeCompare(b.provider), + }, + { + field: 'imported', + label: 'Imported', + sortable: (a, b) => new Date(a.imported_time).getTime() - new Date(b.imported_time).getTime(), + }, + { + field: 'kebab', + label: '', + sortable: false, + }, +]; diff --git a/frontend/src/services/imagesService.ts b/frontend/src/services/imagesService.ts index fd7f68ea19..e806227c24 100644 --- a/frontend/src/services/imagesService.ts +++ b/frontend/src/services/imagesService.ts @@ -1,11 +1,5 @@ import axios from 'axios'; -import { - BYONImage, - BYONImageCreateRequest, - BYONImageUpdateRequest, - ImageInfo, - ResponseStatus, -} from '~/types'; +import { BYONImage, ImageInfo, ResponseStatus } from '~/types'; export const fetchImages = (): Promise => { const url = `/api/images/jupyter`; @@ -27,7 +21,7 @@ export const fetchBYONImages = (): Promise => { }); }; -export const importBYONImage = (image: BYONImageCreateRequest): Promise => { +export const importBYONImage = (image: Partial): Promise => { const url = '/api/images'; return axios .post(url, image) @@ -38,7 +32,7 @@ export const importBYONImage = (image: BYONImageCreateRequest): Promise => { - const url = `/api/images/${image.id}`; + const url = `/api/images/${image.name}`; return axios .delete(url, image) .then((response) => response.data) @@ -47,8 +41,8 @@ export const deleteBYONImage = (image: BYONImage): Promise => { }); }; -export const updateBYONImage = (image: BYONImageUpdateRequest): Promise => { - const url = `/api/images/${image.id}`; +export const updateBYONImage = (image: Partial): Promise => { + const url = `/api/images/${image.name}`; return axios .put(url, image) .then((response) => response.data) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 4cfc66ad41..e3ea8c927f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -438,29 +438,16 @@ export type Route = { export type BYONImage = { id: string; - user?: string; - uploaded?: Date; - error?: string; -} & BYONImageCreateRequest & - BYONImageUpdateRequest; - -export type BYONImageCreateRequest = { + provider: string; + imported_time: string; + error: string; name: string; url: string; - description?: string; - // FIXME: This shouldn't be a user defined value consumed from the request payload but should be a controlled value from an authentication middleware. - user: string; - software?: BYONImagePackage[]; - packages?: BYONImagePackage[]; -}; - -export type BYONImageUpdateRequest = { - id: string; - name?: string; - description?: string; - visible?: boolean; - software?: BYONImagePackage[]; - packages?: BYONImagePackage[]; + display_name: string; + description: string; + visible: boolean; + software: BYONImagePackage[]; + packages: BYONImagePackage[]; }; export type BYONImagePackage = { diff --git a/frontend/src/utilities/NavData.tsx b/frontend/src/utilities/NavData.tsx index b0adb2931c..32916b578a 100644 --- a/frontend/src/utilities/NavData.tsx +++ b/frontend/src/utilities/NavData.tsx @@ -40,7 +40,7 @@ const getSettingsNav = ( if (featureFlagEnabled(dashboardConfig.spec.dashboardConfig.disableBYONImageStream)) { settingsNavs.push({ id: 'settings-notebook-images', - label: 'Notebook images', + label: 'Notebook image settings', href: '/notebookImages', }); } diff --git a/frontend/src/utilities/useWatchBYONImages.ts b/frontend/src/utilities/useWatchBYONImages.ts new file mode 100644 index 0000000000..c7c455e382 --- /dev/null +++ b/frontend/src/utilities/useWatchBYONImages.ts @@ -0,0 +1,11 @@ +import * as React from 'react'; +import { fetchBYONImages } from '~/services/imagesService'; +import { BYONImage } from '~/types'; +import useFetchState, { FetchState } from './useFetchState'; +import { POLL_INTERVAL } from './const'; + +export const useWatchBYONImages = (): FetchState => { + const getBYONImages = React.useCallback(() => fetchBYONImages(), []); + + return useFetchState(getBYONImages, [], { refreshRate: POLL_INTERVAL }); +}; diff --git a/frontend/src/utilities/useWatchBYONImages.tsx b/frontend/src/utilities/useWatchBYONImages.tsx deleted file mode 100644 index 3e980377a9..0000000000 --- a/frontend/src/utilities/useWatchBYONImages.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import * as React from 'react'; -import { fetchBYONImages } from '~/services/imagesService'; -import { BYONImage } from '~/types'; -import { POLL_INTERVAL } from './const'; - -export const useWatchBYONImages = (): { - images: BYONImage[]; - loaded: boolean; - loadError: Error | undefined; - forceUpdate: () => void; -} => { - const [loaded, setLoaded] = React.useState(false); - const [loadError, setLoadError] = React.useState(); - const [images, setImages] = React.useState([]); - const forceUpdate = () => { - setLoaded(false); - fetchBYONImages() - .then((data: BYONImage[]) => { - setLoaded(true); - setLoadError(undefined); - setImages(data); - }) - .catch((e) => { - setLoadError(e); - }); - }; - - React.useEffect(() => { - let watchHandle; - const watchImages = () => { - fetchBYONImages() - .then((data: BYONImage[]) => { - setLoaded(true); - setLoadError(undefined); - setImages(data); - }) - .catch((e) => { - setLoadError(e); - }); - watchHandle = setTimeout(watchImages, POLL_INTERVAL); - }; - watchImages(); - - return () => { - if (watchHandle) { - clearTimeout(watchHandle); - } - }; - // Don't update when components are updated - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return { images: images || [], loaded, loadError, forceUpdate }; -};
- {columnNames.name}{columnNames.description}Enable{columnNames.user}{columnNames.uploaded} -
setBYONImageExpanded(image, !isBYONImageExpanded(image)), - }} - /> - - - {image.name} - - - - - {image.description} - image.id === value.id)?.visible} - onChange={() => { - updateBYONImage({ - id: image.id, - visible: !image.visible, - packages: image.packages, - }); - setBYONImageVisible( - BYONImageVisible.map((value) => - image.id === value.id - ? { id: value.id, visible: !value.visible } - : value, - ), - ); - }} - /> - {image.user} - {relativeTime(currentTimeStamp, new Date(image.uploaded as Date).getTime())} - - -
- {packages.length > 0 ? ( - - - Packages Include - {packages} - - - ) : ( - - - - No packages detected - - Edit the image to add packages - - )} -
- - - - - No results found - - Clear all filters and try again. - - - -
setExpanded(!isExpanded), + }} + /> + + + {obj.display_name} + + + + + {obj.description} + + {obj.provider} + + + {relativeTime(Date.now(), new Date(obj.imported_time).getTime())} + + + + { + onEditImage(obj); + }, + }, + { + isSeparator: true, + }, + { + title: 'Delete', + id: `${obj.name}-delete-button`, + onClick: () => { + onDeleteImage(obj); + }, + }, + ]} + /> +
+ + + + {obj.software.length > 0 && ( + + Displayed software + + {obj.software.map((s, i) => ( +

{`${s.name} ${s.version}`}

+ ))} +
+
+ )} + {obj.packages.length > 0 && ( + + Displayed packages + + {obj.packages.map((p, i) => ( +

{`${p.name} ${p.version}`}

+ ))} +
+
+ )} + + Image location + {obj.url} + +
+
+
- Change the advertised software shown with this notebook image. Modifying the - software here does not effect the contents of the notebook image. -
SoftwareVersion -
- Change the advertised packages shown with this notebook image. Modifying the - packages here does not effect the contents of the notebook image. -
PackageVersion -