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
MaxItemsPerResponse: 1000,
},
ClusterID: '',
+ Containers: {
+ ReserveExtraRAM: 576716800,
+ },
RemoteClusters: {},
Services: {
Controller: { ExternalURL: '' },
//
// 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', () => {
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');
});
});
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);
const FILE_SIZES = [
{
- base: 1099511627776,
+ base: 1024 ** 4,
unit: 'TiB',
},
{
- base: 1073741824,
+ base: 1024 ** 3,
unit: 'GiB',
},
{
- base: 1048576,
+ base: 1024 ** 2,
unit: 'MiB',
},
{
},
];
+const CWL_SIZE = {
+ base: 1024 ** 2,
+ unit: 'MiB',
+};
+
export const formatPropertyValue = (
pv: PropertyValue,
vocabulary?: Vocabulary
return '';
};
-export const formatContainerCost = (cost: number): string => {
+export const formatCost = (cost: number): string => {
const decimalPlaces = 3;
const factor = Math.pow(10, decimalPlaces);
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 {
apiResponse?: boolean;
linked?: boolean;
children?: JSX.Element;
+ inline?: boolean;
}
interface 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>) :
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} />;
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);
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) {
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',
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 });
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';
{ 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 },
+ ]));
+ };
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({
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));
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);
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',
SidePanelTreeCategory.PUBLIC_FAVORITES,
SidePanelTreeCategory.SHARED_WITH_ME,
SidePanelTreeCategory.ALL_PROCESSES,
+ SidePanelTreeCategory.INSTANCE_TYPES,
SidePanelTreeCategory.SHELL_ACCESS,
SidePanelTreeCategory.GROUPS,
SidePanelTreeCategory.TRASH
};
const { items } = await services.projectService.list(params);
-
+
dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
id: projectUuid,
pickerId: SIDE_PANEL_TREE,
import { favoritePanelColumns } from "views/favorite-panel/favorite-panel";
import { matchRootRoute } from "routes/routes";
import {
- setBreadcrumbs,
setGroupDetailsBreadcrumbs,
setGroupsBreadcrumbs,
setProcessBreadcrumbs,
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";
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";
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());
});
navigateToSiteManager,
navigateToSshKeysUser,
navigateToMyAccount,
- navigateToLinkAccount
+ navigateToLinkAccount,
} from 'store/navigation/navigation-action';
import { openUserVirtualMachines } from "store/virtual-machines/virtual-machines-actions";
import { pluginConfig } from 'plugins';
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';
return PublicFavoriteIcon;
case SidePanelTreeCategory.ALL_PROCESSES:
return ProcessIcon;
+ case SidePanelTreeCategory.INSTANCE_TYPES:
+ return ResourceIcon;
case SidePanelTreeCategory.GROUPS:
return GroupsIcon;
case SidePanelTreeCategory.SHELL_ACCESS:
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 {
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',
icon: <ProcessIcon />,
navTarget: navigateToAllProcesses,
},
+ {
+ name: SidePanelCollapsedCategory.INSTANCE_TYPES,
+ icon: <ResourceIcon />,
+ navTarget: navigateToInstanceTypes,
+ },
{
name: SidePanelCollapsedCategory.SHELL_ACCESS,
icon: <TerminalIcon />,
--- /dev/null
+// 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);
+ }
+ });
+});
--- /dev/null
+// 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);
+};
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";
</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}>
import {
CloseIcon,
MaximizeIcon,
- MemoryIcon,
+ ResourceIcon,
UnMaximizeIcon,
} from 'components/icon/icon';
import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
content: classes.title,
avatar: classes.avatar,
}}
- avatar={<MemoryIcon className={classes.iconHeader} />}
+ avatar={<ResourceIcon className={classes.iconHeader} />}
title={
<Typography noWrap variant='h6' color='inherit'>
Resources
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";
path={Routes.SSH_KEYS_ADMIN}
component={SshKeyAdminPanel}
/>
+ <Route
+ path={Routes.INSTANCE_TYPES}
+ component={InstanceTypesPanel}
+ />
<Route
path={Routes.SITE_MANAGER}
component={SiteManagerPanel}
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