diff --git a/frontend/packages/ceph-storage-plugin/src/components/ocs-install/create-form.tsx b/frontend/packages/ceph-storage-plugin/src/components/ocs-install/create-form.tsx new file mode 100644 index 00000000000..0d869bbdc7b --- /dev/null +++ b/frontend/packages/ceph-storage-plugin/src/components/ocs-install/create-form.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { Alert } from '@patternfly/react-core'; +import { K8sResourceKind, K8sKind } from '@console/internal/module/k8s'; +import { ListPage } from '@console/internal/components/factory'; +import { NodeModel } from '@console/internal/models'; +import { ClusterServiceVersionKind } from '@console/internal/components/operator-lifecycle-manager/index'; +import { NodeList } from './node-list'; + +import './ocs-install.scss'; + +export const CreateOCSServiceForm: React.FC = (props) => { + const title = 'Create New OCS Service'; + + return ( +
+

+
{title}
+

+

+ OCS runs as a cloud-native service for optimal integration with applications in need of + storage, and handles the scenes such as provisioning and management. +

+
+
+ +

+ A minimum of 3 nodes needs to be labeled with{' '} + cluster.ocs.openshift.io/openshift-storage="" in order to create + the OCS Service. +

+ +

+ Select at least 3 nodes you wish to use. +

+ } + /> +
+
+
+ ); +}; + +type CreateOCSServiceFormProps = { + operandModel: K8sKind; + sample?: K8sResourceKind; + namespace: string; + clusterServiceVersion: ClusterServiceVersionKind; +}; diff --git a/frontend/packages/ceph-storage-plugin/src/components/ocs-install/create-ocs-service.tsx b/frontend/packages/ceph-storage-plugin/src/components/ocs-install/create-ocs-service.tsx new file mode 100644 index 00000000000..d2053577463 --- /dev/null +++ b/frontend/packages/ceph-storage-plugin/src/components/ocs-install/create-ocs-service.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { match } from 'react-router'; +import { + K8sResourceKind, + K8sKind, + k8sGet, + K8sResourceKindReference, +} from '@console/internal/module/k8s'; +import { BreadCrumbs } from '@console/internal/components/utils/index'; +import { ClusterServiceVersionModel } from '@console/internal/models'; +import { ClusterServiceVersionKind } from '@console/internal/components/operator-lifecycle-manager/index'; +import { OCSServiceModel } from '../../models'; +import { CreateOCSServiceForm } from './create-form'; +import { CreateOCSServiceYAML } from './create-yaml'; + +/** + * Component which wraps the YAML editor and form together + */ +export const CreateOCSService: React.FC = React.memo((props) => { + const [sample, setSample] = React.useState(null); + const [method, setMethod] = React.useState<'yaml' | 'form'>('form'); + const [clusterServiceVersion, setClusterServiceVersion] = React.useState(null); + + React.useEffect(() => { + k8sGet(ClusterServiceVersionModel, props.match.params.appName, props.match.params.ns) + .then((clusterServiceVersionObj) => { + try { + setSample( + JSON.parse(_.get(clusterServiceVersionObj.metadata.annotations, 'alm-examples'))[0], + ); + setClusterServiceVersion(clusterServiceVersionObj); + } catch (e) { + setClusterServiceVersion(null); + } + }) + .catch(() => setClusterServiceVersion(null)); + }, [props.match.params.appName, props.match.params.ns]); + + return ( + +
+
+ {clusterServiceVersion !== null && ( + + )} +
+
+
+ {method === 'form' && ( + + )} +
+ {(method === 'form' && ( + + )) || + (method === 'yaml' && )} +
+ ); +}); + +type CreateOCSServiceProps = { + match: match<{ appName: string; ns: string; plural: K8sResourceKindReference }>; + operandModel: K8sKind; + sample?: K8sResourceKind; + namespace: string; + loadError?: any; + clusterServiceVersion: ClusterServiceVersionKind; +}; diff --git a/frontend/packages/ceph-storage-plugin/src/components/ocs-install/create-yaml.tsx b/frontend/packages/ceph-storage-plugin/src/components/ocs-install/create-yaml.tsx new file mode 100644 index 00000000000..a4a6b477e8e --- /dev/null +++ b/frontend/packages/ceph-storage-plugin/src/components/ocs-install/create-yaml.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { match } from 'react-router'; +import { safeDump } from 'js-yaml'; +import { K8sResourceKind, K8sResourceKindReference } from '@console/internal/module/k8s'; +import { CreateYAML } from '@console/internal/components/create-yaml'; +import { OCSServiceModel } from '../../models'; + +export const CreateOCSServiceYAML: React.FC = (props) => { + const template = _.attempt(() => safeDump(props.sample)); + if (_.isError(template)) { + // eslint-disable-next-line no-console + console.error('Error parsing example JSON from annotation. Falling back to default.'); + } + + return ( + + ); +}; + +type CreateOCSServiceYAMLProps = { + sample?: K8sResourceKind; + match: match<{ appName: string; ns: string; plural: K8sResourceKindReference }>; +}; diff --git a/frontend/packages/ceph-storage-plugin/src/components/ocs-install/node-list.tsx b/frontend/packages/ceph-storage-plugin/src/components/ocs-install/node-list.tsx new file mode 100644 index 00000000000..11d9886cff4 --- /dev/null +++ b/frontend/packages/ceph-storage-plugin/src/components/ocs-install/node-list.tsx @@ -0,0 +1,341 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import * as classNames from 'classnames'; +import { connect } from 'react-redux'; +import { + Table, + TableHeader, + TableBody, + TableVariant, + TableGridBreakpoint, +} from '@patternfly/react-table'; +import { tableFilters } from '@console/internal/components/factory/table-filters'; +import { ActionGroup, Button } from '@patternfly/react-core'; +import { ButtonBar } from '@console/internal/components/utils/button-bar'; +import { history } from '@console/internal/components/utils/router'; +import { + convertToBaseValue, + humanizeCpuCores, + humanizeBinaryBytes, + ResourceLink, +} from '@console/internal/components/utils/index'; +import { + getNodeRoles, + k8sCreate, + k8sPatch, + k8sGet, + K8sResourceKind, + k8sList, + NodeKind, + referenceForModel, + StorageClassResourceKind, +} from '@console/internal/module/k8s'; +import { NodeModel, InfrastructureModel, StorageClassModel } from '@console/internal/models'; +import { OCSServiceModel } from '../../models'; +import { + infraProvisionerMap, + minSelectedNode, + ocsRequestData, + taintObj, +} from '../../constants/ocs-install'; + +import './ocs-install.scss'; + +const ocsLabel = 'cluster.ocs.openshift.io/openshift-storage'; +const nodeLabel = 'cluster.ocs.openshift.io~1openshift-storage'; +const defaultSAnotations = { 'storageclass.kubernetes.io/is-default-class': 'true' }; + +const getConvertedUnits = (value: string) => { + return humanizeBinaryBytes(convertToBaseValue(value)).string || '-'; +}; + +const tableColumnClasses = [ + classNames('col-md-1', 'col-sm-1', 'col-xs-1'), + classNames('col-md-6', 'col-sm-8', 'col-xs-11'), + classNames('col-md-2', 'col-sm-3', 'hidden-xs'), + classNames('col-md-1', 'hidden-sm', 'hidden-xs'), + classNames('col-md-2', 'hidden-sm', 'hidden-xs'), +]; + +const getColumns = () => { + return [ + { + title: 'Name', + props: { className: tableColumnClasses[1] }, + }, + { + title: 'Role', + props: { className: tableColumnClasses[2] }, + }, + { + title: 'CPU', + props: { className: tableColumnClasses[3] }, + }, + { + title: 'Memory', + props: { className: tableColumnClasses[4] }, + }, + ]; +}; + +// return an empty array when there is no data +const getRows = (nodes: NodeKind[]) => { + return nodes.map((node) => { + const roles = getNodeRoles(node).sort(); + const obj = { + cells: [], + selected: false, + id: node.metadata.name, + metadata: _.clone(node.metadata), + spec: _.clone(node.spec), + }; + obj.cells = [ + { + title: , + }, + { + title: roles.join(', ') || '-', + }, + { + title: `${humanizeCpuCores(_.get(node.status, 'capacity.cpu')).string || '-'}`, + }, + { + title: `${getConvertedUnits(_.get(node.status, 'allocatable.memory'))}`, + }, + ]; + return obj; + }); +}; + +const getFilteredRows = (filters: {}, objects: any[]) => { + if (_.isEmpty(filters)) { + return objects; + } + + let filteredObjects = objects; + _.each(filters, (value, name) => { + const filter = tableFilters[name]; + if (_.isFunction(filter)) { + filteredObjects = _.filter(filteredObjects, (o) => filter(value, o)); + } + }); + + return filteredObjects; +}; + +const getPreSelectedNodes = (nodes: formattedNodeType[]) => { + return nodes.map((node) => ({ + ...node, + selected: _.has(node, ['metadata', 'labels', ocsLabel]), + })); +}; + +const stateToProps = (obj, { data = [], filters = {}, staticFilters = [{}] }) => { + const allFilters = staticFilters ? Object.assign({}, filters, ...staticFilters) : filters; + const newData = getFilteredRows(allFilters, data); + return { + data: newData, + }; +}; + +const CustomNodeTable: React.FC = ({ data, loaded, ocsProps }) => { + const columns = getColumns(); + const [nodes, setNodes] = React.useState([]); + const [error, setError] = React.useState(''); + const [inProgress, setProgress] = React.useState(false); + const [selectedNodesCnt, setSelectedNodesCnt] = React.useState(0); + + let storageClass = ''; + + React.useEffect(() => { + const selectedNode = _.filter(nodes, 'selected').length; + setSelectedNodesCnt(selectedNode); + }, [nodes]); + + React.useEffect(() => { + let formattedNodes = getRows(data); + + // pre-selection of nodes + if (loaded && !nodes.length) { + formattedNodes = getPreSelectedNodes(formattedNodes); + setNodes(formattedNodes); + } + // for getting nodes + else if (formattedNodes.length) { + const nodesByID = _.keyBy(nodes, 'id'); + _.each(formattedNodes, (n) => { + n.selected = _.get(nodesByID, [n.id, 'selected']); + }); + setNodes(formattedNodes); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(data), loaded]); + + const onSelect = ( + event: React.MouseEvent, + isSelected: boolean, + index: number, + ) => { + event.stopPropagation(); + let formattedNodes; + if (index === -1) { + formattedNodes = nodes.map((node) => { + node.selected = isSelected; + return node; + }); + } else { + formattedNodes = [...nodes]; + formattedNodes[index].selected = isSelected; + } + setNodes(formattedNodes); + }; + + const makeLabelNodesRequest = (selectedNodes: NodeKind[]): Promise[] => { + return selectedNodes.map((node: NodeKind) => { + const patch = [ + { + op: 'add', + path: `/metadata/labels/${nodeLabel}`, + value: '', + }, + ]; + return k8sPatch(NodeModel, node, patch); + }); + }; + + // tainting the selected nodes + const makeTaintNodesRequest = (selectedNode: NodeKind[]): Promise[] => { + const taintNodesRequest = selectedNode + .filter((node: NodeKind) => { + const roles = getNodeRoles(node); + // don't taint master nodes as its already tainted + return roles.indexOf('master') === -1; + }) + .map((node) => { + const taints = node.spec && node.spec.taints ? [...node.spec.taints, taintObj] : [taintObj]; + const patch = [ + { + value: taints, + path: '/spec/taints', + op: node.spec.taints ? 'replace' : 'add', + }, + ]; + return k8sPatch(NodeModel, node, patch); + }); + + return taintNodesRequest; + }; + + const makeOCSRequest = () => { + const selectedData: NodeKind[] = _.filter(nodes, 'selected'); + const promises = []; + + promises.push(...makeLabelNodesRequest(selectedData)); + promises.push(...makeTaintNodesRequest(selectedData)); + + const ocsObj = _.cloneDeep(ocsRequestData); + ocsObj.spec.storageDeviceSets[0].dataPVCTemplate.spec.storageClassName = storageClass; + promises.push(k8sCreate(OCSServiceModel, ocsObj)); + + Promise.all(promises) + .then(() => { + history.push( + `/k8s/ns/${ocsProps.namespace}/clusterserviceversions/${ + ocsProps.clusterServiceVersion.metadata.name + }/${referenceForModel(OCSServiceModel)}/${ocsObj.metadata.name}`, + ); + setProgress(false); + setError(''); + }) + .catch((err) => { + setProgress(false); + setError(err.message); + }); + }; + + const submit = (event: React.MouseEvent) => { + event.preventDefault(); + setProgress(true); + setError(''); + + let provisioner = ''; + + k8sGet(InfrastructureModel, 'cluster') + .then((infra: K8sResourceKind) => { + // find infra supported provisioner + provisioner = infraProvisionerMap[_.lowerCase(_.get(infra, 'status.platform'))]; + return k8sList(StorageClassModel); + }) + .then((storageClasses: StorageClassResourceKind[]) => { + // find all storageclass with the given provisioner + const scList = _.filter(storageClasses, (sc) => sc.provisioner === provisioner); + // take the default storageclass + _.forEach(scList, (sc) => { + if (sc.metadata && _.isEqual(sc.metadata.annotations, defaultSAnotations)) { + storageClass = sc.metadata.name; + } + }); + makeOCSRequest(); + }) + .catch((err) => { + setProgress(false); + setError(err.message); + }); + }; + + return ( + <> +
+ + + +
+
+

+ {selectedNodesCnt} node(s) selected +

+ + + + + + + + ); +}; + +export const NodeList = connect<{}, CustomNodeTableProps>(stateToProps)(CustomNodeTable); + +type CustomNodeTableProps = { + data: NodeKind[]; + loaded: boolean; + ocsProps: ocsPropsType; +}; + +type ocsPropsType = { + namespace: string; + clusterServiceVersion: K8sResourceKind; +}; + +type formattedNodeType = { + cells: any[]; + selected: boolean; + id: string; + metadata: {}; + spec: {}; +}; diff --git a/frontend/packages/ceph-storage-plugin/src/components/ocs-install/ocs-install.scss b/frontend/packages/ceph-storage-plugin/src/components/ocs-install/ocs-install.scss new file mode 100644 index 00000000000..453d3d5799b --- /dev/null +++ b/frontend/packages/ceph-storage-plugin/src/components/ocs-install/ocs-install.scss @@ -0,0 +1,11 @@ +.ceph-yaml__link { + position: absolute; + margin-top: 1.5rem; + margin-left: 50%; +} + +.node-list__max-height { + max-height: 400px; + overflow: auto; +} + \ No newline at end of file diff --git a/frontend/packages/ceph-storage-plugin/src/constants/ocs-install.ts b/frontend/packages/ceph-storage-plugin/src/constants/ocs-install.ts new file mode 100644 index 00000000000..c5b5bd20f63 --- /dev/null +++ b/frontend/packages/ceph-storage-plugin/src/constants/ocs-install.ts @@ -0,0 +1,45 @@ +import { K8sResourceKind } from '@console/internal/module/k8s'; + +export const minSelectedNode = 3; +export const taintObj = { + key: 'node.ocs.openshift.io/storage', + value: 'true', + effect: 'NoSchedule', +}; + +export const ocsRequestData: K8sResourceKind = { + apiVersion: 'ocs.openshift.io/v1alpha1', + kind: 'StorageCluster', + metadata: { + name: 'ocs-storagecluster', + namespace: 'openshift-storage', + }, + spec: { + managedNodes: false, + storageDeviceSets: [ + { + name: 'ocs-deviceset', + count: 3, + resources: {}, + placement: {}, + dataPVCTemplate: { + spec: { + storageClassName: '', + accessModes: ['ReadWriteOnce'], + volumeMode: 'Block', + resources: { + requests: { + storage: '1Ti', + }, + }, + }, + }, + }, + ], + }, +}; + +export const infraProvisionerMap = { + aws: 'kubernetes.io/aws-ebs', + vsphere: 'kubernetes.io/vsphere-volume', +}; diff --git a/frontend/packages/ceph-storage-plugin/src/models.ts b/frontend/packages/ceph-storage-plugin/src/models.ts index c6159d33c5e..ac6bcfd8a6d 100644 --- a/frontend/packages/ceph-storage-plugin/src/models.ts +++ b/frontend/packages/ceph-storage-plugin/src/models.ts @@ -12,3 +12,16 @@ export const CephClusterModel: K8sKind = { id: 'cephcluster', crd: true, }; + +export const OCSServiceModel: K8sKind = { + label: 'OCS Cluster Service', + labelPlural: 'OCS Cluster Services', + apiVersion: 'v1alpha1', + apiGroup: 'ocs.openshift.io', + plural: 'storageclusters', + abbr: 'OCS', + namespaced: true, + kind: 'StorageCluster', + id: 'ocscluster', + crd: true, +}; diff --git a/frontend/packages/ceph-storage-plugin/src/plugin.ts b/frontend/packages/ceph-storage-plugin/src/plugin.ts index 9e0877fc0ce..396f8ba3b17 100644 --- a/frontend/packages/ceph-storage-plugin/src/plugin.ts +++ b/frontend/packages/ceph-storage-plugin/src/plugin.ts @@ -7,9 +7,12 @@ import { ModelDefinition, Plugin, DashboardsOverviewQuery, + RoutePage, } from '@console/plugin-sdk'; import { GridPosition } from '@console/internal/components/dashboard'; import { OverviewQuery } from '@console/internal/components/dashboards-page/overview-dashboard/queries'; +import { ClusterServiceVersionModel } from '@console/internal/models'; +import { referenceForModel } from '@console/internal/module/k8s'; import * as models from './models'; import { CAPACITY_USAGE_QUERIES, @@ -24,9 +27,13 @@ type ConsumedExtensions = | DashboardsTab | DashboardsCard | DashboardsOverviewHealthPrometheusSubsystem - | DashboardsOverviewQuery; + | DashboardsOverviewQuery + | RoutePage; const CEPH_FLAG = 'CEPH'; +// keeping this for testing, will be removed once ocs operator available +// const apiObjectRef = 'core.libopenstorage.org~v1alpha1~StorageCluster'; +const apiObjectRef = referenceForModel(models.OCSServiceModel); const plugin: Plugin = [ { @@ -49,6 +56,17 @@ const plugin: Plugin = [ title: 'Persistent Storage', }, }, + { + type: 'Page/Route', + properties: { + exact: true, + path: `/k8s/ns/:ns/${ClusterServiceVersionModel.plural}/:appName/${apiObjectRef}/~new`, + loader: () => + import( + './components/ocs-install/create-ocs-service' /* webpackChunkName: "ceph-ocs-service" */ + ).then((m) => m.CreateOCSService), + }, + }, // Ceph Storage Dashboard Left cards { type: 'Dashboards/Card', diff --git a/frontend/public/components/factory/table.tsx b/frontend/public/components/factory/table.tsx index 4f300e81b5e..f794ec0208d 100644 --- a/frontend/public/components/factory/table.tsx +++ b/frontend/public/components/factory/table.tsx @@ -236,13 +236,13 @@ const VirtualBody: React.SFC = (props) => { ); }; -export type RowFunctionArgs = {obj: object, index: number, columns: [], isScrolling: boolean, key: string, style: object, customData?: object}; +export type RowFunctionArgs = {obj: object, index: number, columns: [], isScrolling: boolean, key: string, style: object, customData?: any}; export type RowFunction = (args: RowFunctionArgs) => JSX.Element; export type VirtualBodyProps = { bindBodyRef: Function; cellMeasurementCache: any; - customData?: object; + customData?: any; Row: RowFunction | React.ComponentClass | React.ComponentType; height: number; isScrolling: boolean; @@ -255,7 +255,7 @@ export type VirtualBodyProps = { } export type TableProps = { - customData?: object; + customData?: any; data?: any[]; defaultSortFunc?: string; defaultSortField?: string; @@ -289,7 +289,7 @@ export const Table = connect { static propTypes = { - customData: PropTypes.object, + customData: PropTypes.any, data: PropTypes.array, unfilteredData: PropTypes.array, AllItemsFilteredMsg: PropTypes.func, @@ -415,7 +415,8 @@ export const Table = connect { aria-describedby={describedBy} name={inputName} required={this.props.required} + value={this.props.defaultRequestSizeValue} />