Merge branch '19675-instance-types-panel' into main. Closes #19675
authorStephen Smith <stephen@curii.com>
Wed, 20 Dec 2023 23:11:44 +0000 (18:11 -0500)
committerStephen Smith <stephen@curii.com>
Wed, 20 Dec 2023 23:11:44 +0000 (18:11 -0500)
Arvados-DCO-1.1-Signed-off-by: Stephen Smith <stephen@curii.com>

20 files changed:
services/workbench2/src/common/config.ts
services/workbench2/src/common/formatters.test.ts
services/workbench2/src/common/formatters.ts
services/workbench2/src/components/code-snippet/code-snippet.tsx
services/workbench2/src/components/icon/icon.tsx
services/workbench2/src/routes/route-change-handlers.ts
services/workbench2/src/routes/routes.ts
services/workbench2/src/store/breadcrumbs/breadcrumbs-actions.ts
services/workbench2/src/store/navigation/navigation-action.ts
services/workbench2/src/store/side-panel-tree/side-panel-tree-actions.ts
services/workbench2/src/store/workbench/workbench-actions.ts
services/workbench2/src/views-components/main-app-bar/account-menu.tsx
services/workbench2/src/views-components/side-panel-tree/side-panel-tree.tsx
services/workbench2/src/views-components/side-panel/side-panel-collapsed.tsx
services/workbench2/src/views/instance-types-panel/instance-types-panel.test.tsx [new file with mode: 0644]
services/workbench2/src/views/instance-types-panel/instance-types-panel.tsx [new file with mode: 0644]
services/workbench2/src/views/process-panel/process-details-attributes.tsx
services/workbench2/src/views/process-panel/process-resource-card.tsx
services/workbench2/src/views/workbench/workbench.tsx
services/workbench2/yarn.lock

index eff998ae5ea45cff369d753d249acb8c6a510684..ed99e7d9749491491a1a495ee5fcc4f7dfc50021 100644 (file)
@@ -19,6 +19,25 @@ export interface ClusterConfigJSON {
         MaxItemsPerResponse: number
     },
     ClusterID: string;
+    Containers: {
+        ReserveExtraRAM: number;
+    },
+    InstanceTypes?: {
+        [key: string]: {
+            AddedScratch: number;
+            CUDA?: {
+                DeviceCount: number;
+                DriverVersion: string;
+                HardwareCapability: string;
+            };
+            IncludedScratch: number;
+            Preemptible: boolean;
+            Price: number;
+            ProviderType: string;
+            RAM: number;
+            VCPUs: number;
+        };
+    };
     RemoteClusters: {
         [key: string]: {
             ActivateUsers: boolean
@@ -276,6 +295,9 @@ export const mockClusterConfigJSON = (
         MaxItemsPerResponse: 1000,
     },
     ClusterID: '',
+    Containers: {
+        ReserveExtraRAM: 576716800,
+    },
     RemoteClusters: {},
     Services: {
         Controller: { ExternalURL: '' },
index 048779727e4865724e1bdcd67e862d3012e6a361..cde1a4f9d253f987c3c83962c83cdb197bf5dc9b 100644 (file)
@@ -2,7 +2,57 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { formatUploadSpeed, formatContainerCost } from "./formatters";
+import { formatFileSize, formatUploadSpeed, formatCost, formatCWLResourceSize } from "./formatters";
+
+describe('formatFileSize', () => {
+    it('should pick the largest unit', () => {
+        const base = 1024;
+        const testCases = [
+            {input: 0, output: '0 B'},
+            {input: 1, output: '1 B'},
+            {input: 1023, output: '1023 B'},
+            {input: base, output: '1.0 KiB'},
+            {input: 1.1 * base, output: '1.1 KiB'},
+            {input: 1.5 * base, output: '1.5 KiB'},
+            {input: base ** 2, output: '1.0 MiB'},
+            {input: 1.5 * (base ** 2), output: '1.5 MiB'},
+            {input: base ** 3, output: '1.0 GiB'},
+            {input: base ** 4, output: '1.0 TiB'},
+        ];
+
+        for (const { input, output } of testCases) {
+            expect(formatFileSize(input)).toBe(output);
+        }
+    });
+
+    it('should handle accidental empty string or undefined input', () => {
+        expect(formatFileSize('')).toBe('-');
+        expect(formatFileSize(undefined)).toBe('-');
+    });
+
+    it('should handle accidental non-empty string input', () => {
+        expect(formatFileSize('foo')).toBe('0 B');
+    });
+});
+
+describe('formatCWLResourceSize', () => {
+    it('should format bytes as MiB', () => {
+        const base = 1024 ** 2;
+
+        const testCases = [
+            {input: 0, output: '0 MiB'},
+            {input: 1, output: '0 MiB'},
+            {input: base - 1, output: '1 MiB'},
+            {input: 2 * base, output: '2 MiB'},
+            {input: 1024 * base, output: '1024 MiB'},
+            {input: 10000 * base, output: '10000 MiB'},
+        ];
+
+        for (const { input, output } of testCases) {
+            expect(formatCWLResourceSize(input)).toBe(output);
+        }
+    });
+});
 
 describe('formatUploadSpeed', () => {
     it('should show speed less than 1MB/s', () => {
@@ -30,16 +80,16 @@ describe('formatUploadSpeed', () => {
 
 describe('formatContainerCost', () => {
     it('should correctly round to tenth of a cent', () => {
-        expect(formatContainerCost(0.0)).toBe('$0');
-        expect(formatContainerCost(0.125)).toBe('$0.125');
-        expect(formatContainerCost(0.1254)).toBe('$0.125');
-        expect(formatContainerCost(0.1255)).toBe('$0.126');
+        expect(formatCost(0.0)).toBe('$0');
+        expect(formatCost(0.125)).toBe('$0.125');
+        expect(formatCost(0.1254)).toBe('$0.125');
+        expect(formatCost(0.1255)).toBe('$0.126');
     });
 
     it('should round up any smaller value to 0.001', () => {
-        expect(formatContainerCost(0.0)).toBe('$0');
-        expect(formatContainerCost(0.001)).toBe('$0.001');
-        expect(formatContainerCost(0.0001)).toBe('$0.001');
-        expect(formatContainerCost(0.00001)).toBe('$0.001');
+        expect(formatCost(0.0)).toBe('$0');
+        expect(formatCost(0.001)).toBe('$0.001');
+        expect(formatCost(0.0001)).toBe('$0.001');
+        expect(formatCost(0.00001)).toBe('$0.001');
     });
 });
index a38609a678661c420d5b88a23197540feb859dca..e44a21e026c7c6fda90490a42e020f5bdb53f25b 100644 (file)
@@ -41,6 +41,10 @@ export const formatFileSize = (size?: number | string) => {
     return '0 B';
 };
 
+export const formatCWLResourceSize = (size: number) => {
+    return `${(size / CWL_SIZE.base).toFixed(0)} ${CWL_SIZE.unit}`;
+};
+
 export const formatTime = (time: number, seconds?: boolean) => {
     const minutes = Math.floor((time / (1000 * 60)) % 60).toFixed(0);
     const hours = Math.floor(time / (1000 * 60 * 60)).toFixed(0);
@@ -78,15 +82,15 @@ export function formatUploadSpeed(
 
 const FILE_SIZES = [
     {
-        base: 1099511627776,
+        base: 1024 ** 4,
         unit: 'TiB',
     },
     {
-        base: 1073741824,
+        base: 1024 ** 3,
         unit: 'GiB',
     },
     {
-        base: 1048576,
+        base: 1024 ** 2,
         unit: 'MiB',
     },
     {
@@ -99,6 +103,11 @@ const FILE_SIZES = [
     },
 ];
 
+const CWL_SIZE = {
+    base: 1024 ** 2,
+    unit: 'MiB',
+};
+
 export const formatPropertyValue = (
     pv: PropertyValue,
     vocabulary?: Vocabulary
@@ -116,7 +125,7 @@ export const formatPropertyValue = (
     return '';
 };
 
-export const formatContainerCost = (cost: number): string => {
+export const formatCost = (cost: number): string => {
     const decimalPlaces = 3;
 
     const factor = Math.pow(10, decimalPlaces);
index 5a5a7041d88a630717512e562060f3decaac1c2d..209dbc44b5d37d6221cc6a507fee1401f406dab9 100644 (file)
@@ -12,17 +12,24 @@ import { FederationConfig, getNavUrl } from 'routes/routes';
 import { Dispatch } from 'redux';
 import { navigationNotAvailable } from 'store/navigation/navigation-action';
 
-type CssRules = 'root' | 'space';
+type CssRules = 'root' | 'inlineRoot' | 'space' | 'inline';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
         boxSizing: 'border-box',
         overflow: 'auto',
-        padding: theme.spacing.unit
+        padding: theme.spacing.unit,
+    },
+    inlineRoot: {
+        padding: "3px",
+        display: "inline",
     },
     space: {
-        marginLeft: '15px'
-    }
+        marginLeft: '15px',
+    },
+    inline: {
+        display: 'inline',
+    },
 });
 
 export interface CodeSnippetDataProps {
@@ -31,6 +38,7 @@ export interface CodeSnippetDataProps {
     apiResponse?: boolean;
     linked?: boolean;
     children?: JSX.Element;
+    inline?: boolean;
 }
 
 interface CodeSnippetAuthProps {
@@ -44,11 +52,11 @@ const mapStateToProps = (state: RootState): CodeSnippetAuthProps => ({
 });
 
 export const CodeSnippet = withStyles(styles)(connect(mapStateToProps)(
-    ({ classes, lines, linked, className, apiResponse, dispatch, auth, children }: CodeSnippetProps & CodeSnippetAuthProps & DispatchProp) =>
+    ({ classes, lines, linked, className, apiResponse, dispatch, auth, children, inline }: CodeSnippetProps & CodeSnippetAuthProps & DispatchProp) =>
         <Typography
         component="div"
-        className={classNames(classes.root, className)}>
-            <Typography className={apiResponse ? classes.space : className} component="pre">
+        className={classNames([classes.root, className, inline ? classes.inlineRoot: undefined])}>
+            <Typography className={apiResponse ? classes.space : classNames([className, inline ? classes.inline : undefined])} component="pre">
                 {children}
                 {linked ?
                     lines.map((line, index) => <React.Fragment key={index}>{renderLinks(auth, dispatch)(line)}{`\n`}</React.Fragment>) :
index 998cd8059aabe97f1d56e0e9d6c699dea876e06b..e29930a4b9974b84a54370f7c61b2ee726d27b41 100644 (file)
@@ -209,7 +209,7 @@ export const KeyIcon: IconType = props => <VpnKey {...props} />;
 export const LogIcon: IconType = props => <SettingsEthernet {...props} />;
 export const MailIcon: IconType = props => <Mail {...props} />;
 export const MaximizeIcon: IconType = props => <FullscreenSharp {...props} />;
-export const MemoryIcon: IconType = props => <Memory {...props} />;
+export const ResourceIcon: IconType = props => <Memory {...props} />;
 export const UnMaximizeIcon: IconType = props => <FullscreenExitSharp {...props} />;
 export const MoreVerticalIcon: IconType = props => <MoreVert {...props} />;
 export const MoreHorizontalIcon: IconType = props => <MoreHoriz {...props} />;
index bdc1ddc06325bb473dc7493595df0f42fd56c5a7..58887452533d0df58575b1c6cadf8d610ea889b5 100644 (file)
@@ -36,6 +36,7 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
     const virtualMachineAdminMatch = Routes.matchAdminVirtualMachineRoute(pathname);
     const sshKeysUserMatch = Routes.matchSshKeysUserRoute(pathname);
     const sshKeysAdminMatch = Routes.matchSshKeysAdminRoute(pathname);
+    const instanceTypesMatch = Routes.matchInstanceTypesRoute(pathname);
     const siteManagerMatch = Routes.matchSiteManagerRoute(pathname);
     const keepServicesMatch = Routes.matchKeepServicesRoute(pathname);
     const apiClientAuthorizationsMatch = Routes.matchApiClientAuthorizationsRoute(pathname);
@@ -92,6 +93,8 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
         store.dispatch(WorkbenchActions.loadSshKeys);
     } else if (sshKeysAdminMatch) {
         store.dispatch(WorkbenchActions.loadSshKeys);
+    } else if (instanceTypesMatch) {
+        store.dispatch(WorkbenchActions.loadInstanceTypes);
     } else if (siteManagerMatch) {
         store.dispatch(WorkbenchActions.loadSiteManager);
     } else if (keepServicesMatch) {
index 4dfd998e8dadcc08450fc727b4389c03d199c5c0..cbb09a500d9117d7556bcc5db657199b88994116 100644 (file)
@@ -35,6 +35,7 @@ export const Routes = {
     SEARCH_RESULTS: '/search-results',
     SSH_KEYS_ADMIN: `/ssh-keys-admin`,
     SSH_KEYS_USER: `/ssh-keys-user`,
+    INSTANCE_TYPES: `/instance-types`,
     SITE_MANAGER: `/site-manager`,
     MY_ACCOUNT: '/my-account',
     LINK_ACCOUNT: '/link_account',
@@ -161,9 +162,12 @@ export const matchRepositoriesRoute = (route: string) =>
 export const matchSshKeysUserRoute = (route: string) =>
     matchPath(route, { path: Routes.SSH_KEYS_USER });
 
-export const matchSshKeysAdminRoute = (route: string) =>
+    export const matchSshKeysAdminRoute = (route: string) =>
     matchPath(route, { path: Routes.SSH_KEYS_ADMIN });
 
+export const matchInstanceTypesRoute = (route: string) =>
+    matchPath(route, { path: Routes.INSTANCE_TYPES });
+
 export const matchSiteManagerRoute = (route: string) =>
     matchPath(route, { path: Routes.SITE_MANAGER });
 
index 9aebeb904c64115e574624163718d2fea43bcb82..018a64ce9bc17218ff8962cc3c53b81d92b2e5b1 100644 (file)
@@ -20,7 +20,7 @@ import { ProcessResource } from 'models/process';
 import { OrderBuilder } from 'services/api/order-builder';
 import { Breadcrumb } from 'components/breadcrumbs/breadcrumbs';
 import { ContainerRequestResource, containerRequestFieldsNoMounts } from 'models/container-request';
-import { CollectionIcon, IconType, ProcessIcon, ProjectIcon, WorkflowIcon } from 'components/icon/icon';
+import { AdminMenuIcon, CollectionIcon, IconType, ProcessIcon, ProjectIcon, ResourceIcon, WorkflowIcon } from 'components/icon/icon';
 import { CollectionResource } from 'models/collection';
 import { getSidePanelIcon } from 'views-components/side-panel-tree/side-panel-tree';
 import { WorkflowResource } from 'models/workflow';
@@ -290,3 +290,39 @@ export const setMyAccountBreadcrumbs = () =>
             { label: MY_ACCOUNT_PANEL_LABEL, uuid: MY_ACCOUNT_PANEL_LABEL },
         ]));
     };
+
+export const INSTANCE_TYPES_PANEL_LABEL = 'Instance Types';
+
+export const setInstanceTypesBreadcrumbs = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(setBreadcrumbs([
+            { label: INSTANCE_TYPES_PANEL_LABEL, uuid: INSTANCE_TYPES_PANEL_LABEL, icon: ResourceIcon },
+        ]));
+    };
+
+export const VIRTUAL_MACHINES_USER_PANEL_LABEL = 'Virtual Machines';
+
+export const setVirtualMachinesBreadcrumbs = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(setBreadcrumbs([
+            { label: VIRTUAL_MACHINES_USER_PANEL_LABEL, uuid: VIRTUAL_MACHINES_USER_PANEL_LABEL },
+        ]));
+    };
+
+export const VIRTUAL_MACHINES_ADMIN_PANEL_LABEL = 'Virtual Machines Admin';
+
+export const setVirtualMachinesAdminBreadcrumbs = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(setBreadcrumbs([
+            { label: VIRTUAL_MACHINES_ADMIN_PANEL_LABEL, uuid: VIRTUAL_MACHINES_ADMIN_PANEL_LABEL, icon: AdminMenuIcon },
+        ]));
+    };
+
+export const REPOSITORIES_PANEL_LABEL = 'Repositories';
+
+export const setRepositoriesBreadcrumbs = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(setBreadcrumbs([
+            { label: REPOSITORIES_PANEL_LABEL, uuid: REPOSITORIES_PANEL_LABEL, icon: AdminMenuIcon },
+        ]));
+    };
index 55112fb0ae44ab153e89ce41058205d5fb0d5ca6..3ab5cd9570b1d43a30adf9cb3bd6f9f94cf5ef2b 100644 (file)
@@ -11,7 +11,7 @@ import { RootState } from "store/store";
 import { ServiceRepository } from "services/services";
 import { pluginConfig } from "plugins";
 import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
-import { USERS_PANEL_LABEL, MY_ACCOUNT_PANEL_LABEL } from "store/breadcrumbs/breadcrumbs-actions";
+import { USERS_PANEL_LABEL, MY_ACCOUNT_PANEL_LABEL, INSTANCE_TYPES_PANEL_LABEL, VIRTUAL_MACHINES_USER_PANEL_LABEL, VIRTUAL_MACHINES_ADMIN_PANEL_LABEL, REPOSITORIES_PANEL_LABEL } from "store/breadcrumbs/breadcrumbs-actions";
 
 export const navigationNotAvailable = (id: string) =>
     snackbarActions.OPEN_SNACKBAR({
@@ -78,6 +78,18 @@ export const navigateTo = (uuid: string) => async (dispatch: Dispatch, getState:
         case MY_ACCOUNT_PANEL_LABEL:
             dispatch(navigateToMyAccount);
             return;
+        case INSTANCE_TYPES_PANEL_LABEL:
+            dispatch(navigateToInstanceTypes);
+            return;
+        case VIRTUAL_MACHINES_USER_PANEL_LABEL:
+            dispatch(navigateToUserVirtualMachines);
+            return;
+        case VIRTUAL_MACHINES_ADMIN_PANEL_LABEL:
+            dispatch(navigateToAdminVirtualMachines);
+            return;
+        case REPOSITORIES_PANEL_LABEL:
+            dispatch(navigateToRepositories);
+            return;
     }
 
     dispatch(navigationNotAvailable(uuid));
@@ -132,6 +144,8 @@ export const navigateToSshKeysAdmin = push(Routes.SSH_KEYS_ADMIN);
 
 export const navigateToSshKeysUser = push(Routes.SSH_KEYS_USER);
 
+export const navigateToInstanceTypes = push(Routes.INSTANCE_TYPES);
+
 export const navigateToSiteManager = push(Routes.SITE_MANAGER);
 
 export const navigateToMyAccount = push(Routes.MY_ACCOUNT);
index 44dfe869389fc5598b5fb3a205ebd36390b3037d..3fad5f587a3c41586784987d4925ae01a39d2134 100644 (file)
@@ -24,6 +24,7 @@ export enum SidePanelTreeCategory {
     PUBLIC_FAVORITES = 'Public Favorites',
     SHARED_WITH_ME = 'Shared with me',
     ALL_PROCESSES = 'All Processes',
+    INSTANCE_TYPES = 'Instance Types',
     SHELL_ACCESS = 'Shell Access',
     GROUPS = 'Groups',
     TRASH = 'Trash',
@@ -53,6 +54,7 @@ let SIDE_PANEL_CATEGORIES: string[] = [
     SidePanelTreeCategory.PUBLIC_FAVORITES,
     SidePanelTreeCategory.SHARED_WITH_ME,
     SidePanelTreeCategory.ALL_PROCESSES,
+    SidePanelTreeCategory.INSTANCE_TYPES,
     SidePanelTreeCategory.SHELL_ACCESS,
     SidePanelTreeCategory.GROUPS,
     SidePanelTreeCategory.TRASH
@@ -120,7 +122,7 @@ const loadProject = (projectUuid: string) =>
         };
 
         const { items } = await services.projectService.list(params);
-        
+
         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
             id: projectUuid,
             pickerId: SIDE_PANEL_TREE,
index e89a95e039df04e19abbcf47e00237643b24998a..c6e7ff66d37e9836c4b89c7feae2fe14deafca9e 100644 (file)
@@ -21,7 +21,6 @@ import { projectPanelColumns } from "views/project-panel/project-panel";
 import { favoritePanelColumns } from "views/favorite-panel/favorite-panel";
 import { matchRootRoute } from "routes/routes";
 import {
-    setBreadcrumbs,
     setGroupDetailsBreadcrumbs,
     setGroupsBreadcrumbs,
     setProcessBreadcrumbs,
@@ -31,6 +30,10 @@ import {
     setUsersBreadcrumbs,
     setMyAccountBreadcrumbs,
     setUserProfileBreadcrumbs,
+    setInstanceTypesBreadcrumbs,
+    setVirtualMachinesBreadcrumbs,
+    setVirtualMachinesAdminBreadcrumbs,
+    setRepositoriesBreadcrumbs,
 } from "store/breadcrumbs/breadcrumbs-actions";
 import { navigateTo, navigateToRootProject } from "store/navigation/navigation-action";
 import { MoveToFormDialogData } from "store/move-to-dialog/move-to-dialog";
@@ -95,7 +98,6 @@ import { subprocessPanelActions } from "store/subprocess-panel/subprocess-panel-
 import { subprocessPanelColumns } from "views/subprocess-panel/subprocess-panel-root";
 import { loadAllProcessesPanel, allProcessesPanelActions } from "../all-processes-panel/all-processes-panel-action";
 import { allProcessesPanelColumns } from "views/all-processes-panel/all-processes-panel";
-import { AdminMenuIcon } from "components/icon/icon";
 import { userProfileGroupsColumns } from "views/user-profile-panel/user-profile-panel-root";
 import { selectedToArray, selectedToKindSet } from "components/multiselect-toolbar/MultiselectToolbar";
 import { multiselectActions } from "store/multiselect/multiselect-actions";
@@ -742,23 +744,28 @@ export const loadLinks = handleFirstTimeLoad(async (dispatch: Dispatch<any>) =>
 
 export const loadVirtualMachines = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
     await dispatch(loadVirtualMachinesPanel());
-    dispatch(setBreadcrumbs([{ label: "Virtual Machines" }]));
+    dispatch(setVirtualMachinesBreadcrumbs());
 });
 
 export const loadVirtualMachinesAdmin = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
     await dispatch(loadVirtualMachinesPanel());
-    dispatch(setBreadcrumbs([{ label: "Virtual Machines Admin", icon: AdminMenuIcon }]));
+    dispatch(setVirtualMachinesAdminBreadcrumbs());
 });
 
 export const loadRepositories = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
     await dispatch(loadRepositoriesPanel());
-    dispatch(setBreadcrumbs([{ label: "Repositories" }]));
+    dispatch(setRepositoriesBreadcrumbs());
 });
 
 export const loadSshKeys = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
     await dispatch(loadSshKeysPanel());
 });
 
+export const loadInstanceTypes = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.INSTANCE_TYPES));
+    dispatch(setInstanceTypesBreadcrumbs());
+});
+
 export const loadSiteManager = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
     await dispatch(loadSiteManagerPanel());
 });
index c2cc0e2a47c772502d35f46add124bd62273fbf6..02dd54f1c4366c208f8ba050ed7b05992a31a781 100644 (file)
@@ -17,7 +17,7 @@ import {
     navigateToSiteManager,
     navigateToSshKeysUser,
     navigateToMyAccount,
-    navigateToLinkAccount
+    navigateToLinkAccount,
 } from 'store/navigation/navigation-action';
 import { openUserVirtualMachines } from "store/virtual-machines/virtual-machines-actions";
 import { pluginConfig } from 'plugins';
index 19ab3184af88e2f042afb7ac1b02c98bcbfa877e..f8cd9efe46f76f4e5a9eea7c9f85915456512d6e 100644 (file)
@@ -9,7 +9,7 @@ import { TreePicker, TreePickerProps } from "../tree-picker/tree-picker";
 import { TreeItem } from "components/tree/tree";
 import { ProjectResource } from "models/project";
 import { ListItemTextIcon } from "components/list-item-text-icon/list-item-text-icon";
-import { ProcessIcon, ProjectIcon, FilterGroupIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon, TrashIcon, PublicFavoriteIcon, GroupsIcon, TerminalIcon } from 'components/icon/icon';
+import { ProcessIcon, ProjectIcon, FilterGroupIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon, TrashIcon, PublicFavoriteIcon, GroupsIcon, TerminalIcon, ResourceIcon } from 'components/icon/icon';
 import { activateSidePanelTreeItem, toggleSidePanelTreeItemCollapse, SIDE_PANEL_TREE, SidePanelTreeCategory } from 'store/side-panel-tree/side-panel-tree-actions';
 import { openSidePanelContextMenu } from 'store/context-menu/context-menu-actions';
 import { noop } from 'lodash';
@@ -80,6 +80,8 @@ export const getSidePanelIcon = (category: string) => {
             return PublicFavoriteIcon;
         case SidePanelTreeCategory.ALL_PROCESSES:
             return ProcessIcon;
+        case SidePanelTreeCategory.INSTANCE_TYPES:
+            return ResourceIcon;
         case SidePanelTreeCategory.GROUPS:
             return GroupsIcon;
         case SidePanelTreeCategory.SHELL_ACCESS:
index d2f5cfec3b43b7850f8008c2977502ac1f011da8..9948ace35daadc5fceeff3e4fb4431d70be4e86d 100644 (file)
@@ -4,12 +4,12 @@
 
 import React, { ReactElement } from 'react'
 import { connect } from 'react-redux'
-import { ProjectsIcon, ProcessIcon, FavoriteIcon, ShareMeIcon, TrashIcon, PublicFavoriteIcon, GroupsIcon } from 'components/icon/icon'
+import { ProjectsIcon, ProcessIcon, FavoriteIcon, ShareMeIcon, TrashIcon, PublicFavoriteIcon, GroupsIcon, ResourceIcon } from 'components/icon/icon'
 import { TerminalIcon } from 'components/icon/icon'
 import { IconButton, List, ListItem, Tooltip } from '@material-ui/core'
 import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles'
 import { ArvadosTheme } from 'common/custom-theme'
-import { navigateTo } from 'store/navigation/navigation-action'
+import { navigateTo, navigateToInstanceTypes } from 'store/navigation/navigation-action'
 import { RootState } from 'store/store'
 import { Dispatch } from 'redux'
 import {
@@ -48,6 +48,7 @@ enum SidePanelCollapsedCategory {
     PUBLIC_FAVORITES = 'Public Favorites',
     SHARED_WITH_ME = 'Shared with me',
     ALL_PROCESSES = 'All Processes',
+    INSTANCE_TYPES = 'Instance Types',
     SHELL_ACCESS = 'Shell Access',
     GROUPS = 'Groups',
     TRASH = 'Trash',
@@ -85,6 +86,11 @@ const sidePanelCollapsedCategories: TCollapsedCategory[] = [
         icon: <ProcessIcon />,
         navTarget: navigateToAllProcesses,
     },
+    {
+        name: SidePanelCollapsedCategory.INSTANCE_TYPES,
+        icon: <ResourceIcon />,
+        navTarget: navigateToInstanceTypes,
+    },
     {
         name: SidePanelCollapsedCategory.SHELL_ACCESS,
         icon: <TerminalIcon />,
diff --git a/services/workbench2/src/views/instance-types-panel/instance-types-panel.test.tsx b/services/workbench2/src/views/instance-types-panel/instance-types-panel.test.tsx
new file mode 100644 (file)
index 0000000..a9d87b9
--- /dev/null
@@ -0,0 +1,112 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { configure, mount } from "enzyme";
+import { InstanceTypesPanel, calculateKeepBufferOverhead, discountRamByPercent } from './instance-types-panel';
+import Adapter from "enzyme-adapter-react-16";
+import { combineReducers, createStore } from "redux";
+import { Provider } from "react-redux";
+import { formatFileSize, formatCWLResourceSize } from 'common/formatters';
+
+configure({ adapter: new Adapter() });
+
+describe('<InstanceTypesPanel />', () => {
+
+    // let props;
+    let store;
+
+    const initialAuthState = {
+        config: {
+            clusterConfig: {
+                InstanceTypes: {
+                    "normalType" : {
+                        ProviderType: "provider",
+                        Price: 0.123,
+                        VCPUs: 6,
+                        Preemptible: false,
+                        IncludedScratch: 1000,
+                        RAM: 5000,
+                    },
+                    "gpuType" : {
+                        ProviderType: "gpuProvider",
+                        Price: 0.456,
+                        VCPUs: 8,
+                        Preemptible: true,
+                        IncludedScratch: 500,
+                        RAM: 6000,
+                        CUDA: {
+                            DeviceCount: 1,
+                            HardwareCapability: '8.6',
+                            DriverVersion: '11.4',
+                        },
+                    },
+                },
+                Containers: {
+                    ReserveExtraRAM: 1000,
+                }
+            }
+        }
+    }
+
+    beforeEach(() => {
+
+        store = createStore(combineReducers({
+            auth: (state: any = initialAuthState, action: any) => state,
+        }));
+    });
+
+    it('renders instance types', () => {
+        // when
+        const panel = mount(
+            <Provider store={store}>
+                <InstanceTypesPanel />
+            </Provider>);
+
+        // then
+        Object.keys(initialAuthState.config.clusterConfig.InstanceTypes).forEach((instanceKey) => {
+            const instanceType = initialAuthState.config.clusterConfig.InstanceTypes[instanceKey];
+            const item = panel.find(`Grid[data-cy="${instanceKey}"]`)
+
+            expect(item.find('h6').text()).toContain(instanceKey);
+            expect(item.text()).toContain(`Provider type${instanceType.ProviderType}`);
+            expect(item.text()).toContain(`Price$${instanceType.Price}`);
+            expect(item.text()).toContain(`Cores${instanceType.VCPUs}`);
+            expect(item.text()).toContain(`Preemptible${instanceType.Preemptible.toString()}`);
+            expect(item.text()).toContain(`Max disk request${formatCWLResourceSize(instanceType.IncludedScratch)} (${formatFileSize(instanceType.IncludedScratch)})`);
+            if (instanceType.CUDA && instanceType.CUDA.DeviceCount > 0) {
+                expect(item.text()).toContain(`CUDA GPUs${instanceType.CUDA.DeviceCount}`);
+                expect(item.text()).toContain(`Hardware capability${instanceType.CUDA.HardwareCapability}`);
+                expect(item.text()).toContain(`Driver version${instanceType.CUDA.DriverVersion}`);
+            }
+        });
+    });
+});
+
+describe('calculateKeepBufferOverhead', () => {
+    it('should calculate correct buffer size', () => {
+        const testCases = [
+            {input: 0, output: (220<<20)},
+            {input: 1, output: (220<<20) + ((1<<26) * (11/10))},
+            {input: 2, output: (220<<20) + 2*((1<<26) * (11/10))},
+        ];
+
+        for (const {input, output} of testCases) {
+            expect(calculateKeepBufferOverhead(input)).toBe(output);
+        }
+    });
+});
+
+describe('discountRamByPercent', () => {
+    it('should inflate ram requirement by 5% of final amount', () => {
+        const testCases = [
+            {input: 0, output: 0},
+            {input: 114, output: 120},
+        ];
+
+        for (const {input, output} of testCases) {
+            expect(discountRamByPercent(input)).toBe(output);
+        }
+    });
+});
diff --git a/services/workbench2/src/views/instance-types-panel/instance-types-panel.tsx b/services/workbench2/src/views/instance-types-panel/instance-types-panel.tsx
new file mode 100644 (file)
index 0000000..2f240c8
--- /dev/null
@@ -0,0 +1,148 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import { StyleRulesCallback, WithStyles, withStyles, Card, CardContent, Typography, Grid } from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { ResourceIcon } from 'components/icon/icon';
+import { RootState } from 'store/store';
+import { connect } from 'react-redux';
+import { ClusterConfigJSON } from 'common/config';
+import { NotFoundView } from 'views/not-found-panel/not-found-panel';
+import { formatCWLResourceSize, formatCost, formatFileSize } from 'common/formatters';
+import { DetailsAttribute } from 'components/details-attribute/details-attribute';
+import { DefaultCodeSnippet } from 'components/default-code-snippet/default-code-snippet';
+
+type CssRules = 'root' | 'infoBox' | 'instanceType';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+       width: "calc(100% + 20px)",
+       margin: "0 -10px",
+       overflow: 'auto'
+    },
+    infoBox: {
+        padding: "0 10px 10px",
+    },
+    instanceType: {
+        padding: "10px",
+    },
+});
+
+type InstanceTypesPanelConnectedProps = {config: ClusterConfigJSON};
+
+type InstanceTypesPanelRootProps = InstanceTypesPanelConnectedProps & WithStyles<CssRules>;
+
+const mapStateToProps = ({auth}: RootState): InstanceTypesPanelConnectedProps => ({
+    config: auth.config.clusterConfig,
+});
+
+export const InstanceTypesPanel = withStyles(styles)(connect(mapStateToProps)(
+    ({ config, classes }: InstanceTypesPanelRootProps) => {
+
+        const instances = config.InstanceTypes || {};
+
+        return <Grid className={classes.root} container direction="row">
+            <Grid className={classes.infoBox} item xs={12}>
+                <Card>
+                    <CardContent>
+                        <Typography variant="body2">
+                            These are the cloud compute instance types
+                            configured for this cluster. The core count and
+                            maximum RAM request correspond to the greatest
+                            values you can put in the CWL Workflow
+                            ResourceRequest{" "}
+                            <DefaultCodeSnippet
+                                inline
+                                lines={["minCores"]}
+                            />{" "}
+                            and{" "}
+                            <DefaultCodeSnippet inline lines={["minRAM"]} />{" "}
+                            and still be scheduled on that instance type.
+                        </Typography>
+                    </CardContent>
+                </Card>
+            </Grid>
+            {Object.keys(instances).length > 0 ?
+                Object.keys(instances)
+                    .sort((a, b) => {
+                        const typeA = instances[a];
+                        const typeB = instances[b];
+
+                        if (typeA.Price !== typeB.Price) {
+                            return typeA.Price - typeB.Price;
+                        } else {
+                            return typeA.ProviderType.localeCompare(typeB.ProviderType);
+                        }
+                    }).map((instanceKey) => {
+                        const instanceType = instances[instanceKey];
+                        const maxDiskRequest = instanceType.IncludedScratch;
+                        const keepBufferOverhead = calculateKeepBufferOverhead(instanceType.VCPUs);
+                        const maxRamRequest = discountRamByPercent(instanceType.RAM - config.Containers.ReserveExtraRAM - keepBufferOverhead);
+
+                        return <Grid data-cy={instanceKey} className={classes.instanceType} item sm={6} xs={12} key={instanceKey}>
+                            <Card>
+                                <CardContent>
+                                    <Typography variant="h6">
+                                        {instanceKey}
+                                    </Typography>
+                                    <Grid item xs={12}>
+                                        <DetailsAttribute label="Provider type" value={instanceType.ProviderType} />
+                                    </Grid>
+                                    <Grid item xs={12}>
+                                        <DetailsAttribute label="Price" value={formatCost(instanceType.Price)} />
+                                    </Grid>
+                                    <Grid item xs={12}>
+                                        <DetailsAttribute label="Cores" value={instanceType.VCPUs} />
+                                    </Grid>
+                                    <Grid item xs={12}>
+                                        <DetailsAttribute label="Max RAM request" value={`${formatCWLResourceSize(maxRamRequest)} (${formatFileSize(maxRamRequest)})`} />
+                                    </Grid>
+                                    <Grid item xs={12}>
+                                        <DetailsAttribute label="Max disk request" value={`${formatCWLResourceSize(maxDiskRequest)} (${formatFileSize(maxDiskRequest)})`} />
+                                    </Grid>
+                                    <Grid item xs={12}>
+                                        <DetailsAttribute label="Preemptible" value={instanceType.Preemptible.toString()} />
+                                    </Grid>
+                                    {instanceType.CUDA && instanceType.CUDA.DeviceCount > 0 ?
+                                        <>
+                                            <Grid item xs={12}>
+                                                <DetailsAttribute label="CUDA GPUs" value={instanceType.CUDA.DeviceCount} />
+                                            </Grid>
+                                            <Grid item xs={12}>
+                                                <DetailsAttribute label="Hardware capability" value={instanceType.CUDA.HardwareCapability} />
+                                            </Grid>
+                                            <Grid item xs={12}>
+                                                <DetailsAttribute label="Driver version" value={instanceType.CUDA.DriverVersion} />
+                                            </Grid>
+                                        </> : <></>
+                                    }
+                                </CardContent>
+                            </Card>
+                        </Grid>;
+                    }) :
+                <NotFoundView
+                    icon={ResourceIcon}
+                    messages={["No instances found"]}
+                />
+            }
+        </Grid>;
+    }
+));
+
+export const calculateKeepBufferOverhead = (coreCount: number): number => {
+    // TODO replace with exported server config
+    const buffersPerVCPU = 1;
+
+    // Returns 220 MiB + 64MiB+10% per buffer
+    return (220 << 20) + (buffersPerVCPU * coreCount * (1 << 26) * (11/10))
+};
+
+export const discountRamByPercent = (requestedRamBytes: number): number => {
+    // TODO replace this with exported server config or remove when no longer
+    // used by server in ram calculation
+    const discountPercent = 5;
+
+    return requestedRamBytes * 100 / (100-discountPercent);
+};
index ffacd967f4d8e289edf6f9bf66bf31a3c59d2ea1..4e5c038386f9ce400f0943e9ae80701af29513b4 100644 (file)
@@ -5,7 +5,7 @@
 import React from "react";
 import { Grid, StyleRulesCallback, withStyles } from "@material-ui/core";
 import { Dispatch } from 'redux';
-import { formatContainerCost, formatDate } from "common/formatters";
+import { formatCost, formatDate } from "common/formatters";
 import { resourceLabel } from "common/labels";
 import { DetailsAttribute } from "components/details-attribute/details-attribute";
 import { ResourceKind } from "models/resource";
@@ -162,7 +162,7 @@ export const ProcessDetailsAttributes = withStyles(styles, { withTheme: true })(
                 </Grid>
                 {container && <Grid item xs={12} md={mdSize}>
                     <DetailsAttribute label='Cost' value={
-                        `${hasTotalCost ? formatContainerCost(containerRequest.cumulativeCost) + ' total, ' : (totalCostNotReady ? 'total pending completion, ' : '')}${container.cost > 0 ? formatContainerCost(container.cost) : 'not available'} for this container`
+                        `${hasTotalCost ? formatCost(containerRequest.cumulativeCost) + ' total, ' : (totalCostNotReady ? 'total pending completion, ' : '')}${container.cost > 0 ? formatCost(container.cost) : 'not available'} for this container`
                     } />
 
                     {container && workflowCollection && <Grid item xs={12} md={mdSize}>
index b39f48ea12af81634b8e801159c0f9bf41489563..4e849173fb3f5b17655a8de0e542b1e692255337 100644 (file)
@@ -19,7 +19,7 @@ import { ArvadosTheme } from 'common/custom-theme';
 import {
     CloseIcon,
     MaximizeIcon,
-    MemoryIcon,
+    ResourceIcon,
     UnMaximizeIcon,
 } from 'components/icon/icon';
 import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
@@ -88,7 +88,7 @@ export const ProcessResourceCard = withStyles(styles)(connect()(
                     content: classes.title,
                     avatar: classes.avatar,
                 }}
-                avatar={<MemoryIcon className={classes.iconHeader} />}
+                avatar={<ResourceIcon className={classes.iconHeader} />}
                 title={
                     <Typography noWrap variant='h6' color='inherit'>
                         Resources
index 4a2cd7009804335bfb5534946cc7cbbf529d46e9..05ea215dd9a2de0716b17a2778eec722ac789c18 100644 (file)
@@ -106,6 +106,7 @@ import { pluginConfig } from "plugins";
 import { ElementListReducer } from "common/plugintypes";
 import { COLLAPSE_ICON_SIZE } from "views-components/side-panel-toggle/side-panel-toggle";
 import { Banner } from "views-components/baner/banner";
+import { InstanceTypesPanel } from "views/instance-types-panel/instance-types-panel";
 
 type CssRules = "root" | "container" | "splitter" | "asidePanel" | "contentWrapper" | "content";
 
@@ -228,6 +229,10 @@ let routes = (
             path={Routes.SSH_KEYS_ADMIN}
             component={SshKeyAdminPanel}
         />
+        <Route
+            path={Routes.INSTANCE_TYPES}
+            component={InstanceTypesPanel}
+        />
         <Route
             path={Routes.SITE_MANAGER}
             component={SiteManagerPanel}
index 2e0c4f2a1e2dd0b18d9e71faf93c262cc90f40cd..18934f24c7e1d8e9d3ad777d0a324d7eece78075 100644 (file)
@@ -5138,9 +5138,9 @@ __metadata:
   linkType: hard
 
 "caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30000981, caniuse-lite@npm:^1.0.30001035, caniuse-lite@npm:^1.0.30001109, caniuse-lite@npm:^1.0.30001541":
-  version: 1.0.30001561
-  resolution: "caniuse-lite@npm:1.0.30001561"
-  checksum: 949829fe037e23346595614e01d362130245920503a12677f2506ce68e1240360113d6383febed41e8aa38cd0f5fd9c69c21b0af65a71c0246d560db489f1373
+  version: 1.0.30001570
+  resolution: "caniuse-lite@npm:1.0.30001570"
+  checksum: 460be2c7a9b1c8a83b6aae4226661c276d9dada6c84209dee547699cf4b28030b9d1fc29ddd7626acee77412b6401993878ea0ef3eadbf3a63ded9034896ae20
   languageName: node
   linkType: hard