diff --git a/frontend/__tests__/components/utils/firehose.spec.tsx b/frontend/__tests__/components/utils/firehose.spec.tsx index b912f298a323..03823f21180a 100644 --- a/frontend/__tests__/components/utils/firehose.spec.tsx +++ b/frontend/__tests__/components/utils/firehose.spec.tsx @@ -6,7 +6,7 @@ import { Map as ImmutableMap } from 'immutable'; import Spy = jasmine.Spy; import { Firehose } from '../../../public/components/utils/firehose'; -import { FirehoseResource } from '../../../public/components/factory'; +import { FirehoseResource } from '../../../public/components/utils'; import { K8sKind, K8sResourceKindReference } from '../../../public/module/k8s'; import { PodModel, ServiceModel } from '../../../public/models'; diff --git a/frontend/__tests__/extend/kubevirt/module/k8s/vms.spec.js b/frontend/__tests__/extend/kubevirt/module/k8s/vms.spec.js new file mode 100644 index 000000000000..f9af85d7fe38 --- /dev/null +++ b/frontend/__tests__/extend/kubevirt/module/k8s/vms.spec.js @@ -0,0 +1,37 @@ +import * as _ from 'lodash'; + +import { VirtualMachineModel } from '../../../../../public/models'; +import { getVMStatus } from '../../../../../public/extend/kubevirt/module/k8s/vms'; + +// TODO: add this to __mocks__/k8sResourcesMocks module, should be in sync +// with VirtualMachine YAML template defined in public/models/yaml-templates +const testVirtualMachine = { + apiVersion: `${VirtualMachineModel.apiGroup}/${VirtualMachineModel.apiVersion}`, + kind: 'VirtualMachine', + metadata: { + name: 'example', + namespace: 'default', + }, + spec: { + running: false, + template: { + // TODO: empty for now, see above comment + }, + }, +}; + +describe('getVMStatus', () => { + it('returns the status string based on spec.running', () => { + const vm1 = _.cloneDeep(testVirtualMachine); + vm1.spec.running = true; + expect(getVMStatus(vm1)).toBe('Running'); + + const vm2 = _.cloneDeep(testVirtualMachine); + vm2.spec.running = false; + expect(getVMStatus(vm2)).toBe('Stopped'); + + const vm3 = _.cloneDeep(testVirtualMachine); + vm3.spec.running = undefined; + expect(getVMStatus(vm3)).toBe('Stopped'); + }); +}); diff --git a/frontend/__tests__/features.spec.tsx b/frontend/__tests__/features.spec.tsx index df08f938c8a0..cd8844fdbbbc 100644 --- a/frontend/__tests__/features.spec.tsx +++ b/frontend/__tests__/features.spec.tsx @@ -44,6 +44,7 @@ describe('featureReducer', () => { [FLAGS.OPERATOR_HUB]: false, [FLAGS.CLUSTER_API]: false, [FLAGS.MACHINE_CONFIG]: false, + [FLAGS.KUBEVIRT]: false, })); }); }); diff --git a/frontend/integration-tests/preload.ts b/frontend/integration-tests/preload.ts new file mode 100644 index 000000000000..fe79a3e82efa --- /dev/null +++ b/frontend/integration-tests/preload.ts @@ -0,0 +1,24 @@ +/* eslint-disable no-undef */ + +import { addAlias } from 'module-alias'; + +// The `lodash-es` package ships code that uses ECMAScript modules. Since `ts-node` +// excludes everything under `node_modules` directory from compilation, attempting +// to import `lodash-es` code yields syntax errors. +// +// Integration tests already use the `lodash` package, so we simply register alias +// to `lodash` in context of Node.js module resolution mechanism. + +addAlias('lodash-es', 'lodash'); + +declare global { + interface window { + SERVER_FLAGS: object; + } +} + +(global as any).window = { + SERVER_FLAGS: { + basePath: '/', + }, +}; diff --git a/frontend/integration-tests/protractor.conf.ts b/frontend/integration-tests/protractor.conf.ts index 2a895e0ecf7b..e0a1943d55df 100644 --- a/frontend/integration-tests/protractor.conf.ts +++ b/frontend/integration-tests/protractor.conf.ts @@ -104,6 +104,7 @@ export const config: Config = { performance: ['tests/base.scenario.ts', 'tests/performance.scenario.ts'], serviceCatalog: ['tests/base.scenario.ts', 'tests/service-catalog/service-catalog.scenario.ts', 'tests/service-catalog/service-broker.scenario.ts', 'tests/service-catalog/service-class.scenario.ts', 'tests/service-catalog/service-binding.scenario.ts', 'tests/developer-catalog.scenario.ts'], overview: ['tests/base.scenario.ts', 'tests/overview/overview.scenario.ts'], + kubevirt: ['tests/base.scenario.ts', 'tests/kubevirt/vm.actions.scenario.ts'], e2e: [ 'tests/base.scenario.ts', 'tests/crud.scenario.ts', diff --git a/frontend/integration-tests/tests/crud.scenario.ts b/frontend/integration-tests/tests/crud.scenario.ts index 6d036cd98c69..75bab34af369 100644 --- a/frontend/integration-tests/tests/crud.scenario.ts +++ b/frontend/integration-tests/tests/crud.scenario.ts @@ -11,13 +11,15 @@ import * as crudView from '../views/crud.view'; import * as yamlView from '../views/yaml.view'; import * as namespaceView from '../views/namespace.view'; import * as createRoleBindingView from '../views/create-role-binding.view'; +import { referenceForModel, kindForReference } from '../../public/module/k8s/k8s'; +import { ClusterServiceBrokerModel, VirtualMachineModel } from '../../public/models'; const K8S_CREATION_TIMEOUT = 15000; describe('Kubernetes resource CRUD operations', () => { const testLabel = 'automatedTestName'; const leakedResources = new Set(); - const k8sObjs = OrderedMap() + const k8sObjs = OrderedMap() .set('pods', {kind: 'Pod'}) .set('services', {kind: 'Service'}) .set('serviceaccounts', {kind: 'ServiceAccount'}) @@ -38,15 +40,18 @@ describe('Kubernetes resource CRUD operations', () => { .set('horizontalpodautoscalers', {kind: 'HorizontalPodAutoscaler'}) .set('networkpolicies', {kind: 'NetworkPolicy'}) .set('roles', {kind: 'Role'}); - const openshiftObjs = OrderedMap() + const openshiftObjs = OrderedMap() .set('deploymentconfigs', {kind: 'DeploymentConfig'}) .set('buildconfigs', {kind: 'BuildConfig'}) .set('imagestreams', {kind: 'ImageStream'}) .set('routes', {kind: 'Route'}); - const serviceCatalogObjs = OrderedMap() - .set('clusterservicebrokers', {kind: 'servicecatalog.k8s.io~v1beta1~ClusterServiceBroker', namespaced: false}); + const serviceCatalogObjs = OrderedMap() + .set('clusterservicebrokers', {kind: referenceForModel(ClusterServiceBrokerModel), namespaced: false}); + const kubevirtObjs = OrderedMap() + .set('virtualmachines', {kind: referenceForModel(VirtualMachineModel), crd: true}); let testObjs = browser.params.openshift === 'true' ? k8sObjs.merge(openshiftObjs) : k8sObjs; testObjs = browser.params.servicecatalog === 'true' ? testObjs.merge(serviceCatalogObjs) : testObjs; + testObjs = browser.params.kubevirt === 'true' ? testObjs.merge(kubevirtObjs) : testObjs; afterEach(() => { checkLogs(); @@ -67,9 +72,11 @@ describe('Kubernetes resource CRUD operations', () => { }); }); - testObjs.forEach(({kind, namespaced = true}, resource) => { + testObjs.forEach(({kind, namespaced = true, crd = false}, resource) => { + const simpleKind = kindForReference(kind); + const suiteDesc = crd ? `${simpleKind} (CRD)` : kind; - describe(kind, () => { + describe(suiteDesc, () => { const name = `${testName}-${kind.toLowerCase()}`; it('displays a list view for the resource', async() => { await browser.get(`${appHost}${namespaced ? `/k8s/ns/${testName}` : '/k8s/cluster'}/${resource}?name=${testName}`); @@ -138,7 +145,7 @@ describe('Kubernetes resource CRUD operations', () => { await browser.get(`${appHost}/search/${namespaced ? `ns/${testName}` : 'all-namespaces'}?kind=${kind}&q=${testLabel}%3d${testName}`); await crudView.filterForName(name); await crudView.resourceRowsPresent(); - await crudView.editRow(kind)(name); + await crudView.editRow(simpleKind)(name); } }); @@ -148,7 +155,7 @@ describe('Kubernetes resource CRUD operations', () => { // Filter by resource name to make sure the resource is on the first page of results. // Otherwise the tests fail since we do virtual scrolling and the element isn't found. await crudView.filterForName(name); - await crudView.deleteRow(kind)(name); + await crudView.deleteRow(simpleKind)(name); leakedResources.delete(JSON.stringify({name, plural: resource, namespace: namespaced ? testName : undefined})); }); }); @@ -365,3 +372,9 @@ describe('Kubernetes resource CRUD operations', () => { }); }); }); + +type ObjMapValue = { + kind: string; + namespaced?: boolean; + crd?: boolean; +}; diff --git a/frontend/integration-tests/tests/kubevirt/mocks.ts b/frontend/integration-tests/tests/kubevirt/mocks.ts new file mode 100644 index 000000000000..4a62be038f79 --- /dev/null +++ b/frontend/integration-tests/tests/kubevirt/mocks.ts @@ -0,0 +1,63 @@ +/* eslint-disable no-undef */ +import { testName } from '../../protractor.conf'; + +const testLabel = 'automatedTestName'; + +export const testVM = { + apiVersion: 'kubevirt.io/v1alpha2', + kind: 'VirtualMachine', + metadata: { + name: `vm-${testName}`, + namespace: testName, + labels: {[testLabel]: testName}, + }, + spec: { + running: false, + template: { + spec: { + domain: { + cpu: { + cores: 1, + }, + devices: { + disks: [ + { + bootOrder: 1, + disk: { + bus: 'virtio', + }, + name: 'rootdisk', + volumeName: 'rootdisk', + }, + ], + interfaces: [ + { + bridge: {}, + name: 'eth0', + }, + ], + }, + resources: { + requests: { + memory: '1G', + }, + }, + }, + networks: [ + { + name: 'eth0', + pod: {}, + }, + ], + volumes: [ + { + containerDisk: { + image: 'kubevirt/cirros-registry-disk-demo:latest', + }, + name: 'rootdisk', + }, + ], + }, + }, + }, +}; diff --git a/frontend/integration-tests/tests/kubevirt/utils.ts b/frontend/integration-tests/tests/kubevirt/utils.ts new file mode 100644 index 000000000000..caa08f4e3471 --- /dev/null +++ b/frontend/integration-tests/tests/kubevirt/utils.ts @@ -0,0 +1,17 @@ +/* eslint-disable no-undef */ +import { execSync } from 'child_process'; + +export function removeLeakedResources(leakedResources: Set){ + const leakedArray: Array = [...leakedResources]; + if (leakedArray.length > 0) { + console.error(`Leaked ${leakedArray.join()}`); + leakedArray.map(r => JSON.parse(r) as {name: string, namespace: string, kind: string}) + .forEach(({name, namespace, kind}) => { + try { + execSync(`kubectl delete -n ${namespace} --cascade ${kind} ${name}`); + } catch (error) { + console.error(`Failed to delete ${kind} ${name}:\n${error}`); + } + }); + } +} diff --git a/frontend/integration-tests/tests/kubevirt/vm.actions.scenario.ts b/frontend/integration-tests/tests/kubevirt/vm.actions.scenario.ts new file mode 100644 index 000000000000..55155bfe4f44 --- /dev/null +++ b/frontend/integration-tests/tests/kubevirt/vm.actions.scenario.ts @@ -0,0 +1,96 @@ +/* eslint-disable no-undef */ + +import { execSync } from 'child_process'; +import { browser, ExpectedConditions as until } from 'protractor'; + +import { appHost, testName } from '../../protractor.conf'; +import { resourceRowsPresent, filterForName, isLoaded } from '../../views/crud.view'; +import { testVM } from './mocks'; +import { removeLeakedResources } from './utils'; +import { detailViewAction, detailViewVMmStatus, listViewAction, listViewVMmStatus } from '../../views/kubevirt/vm.actions.view'; + +const VM_BOOTUP_TIMEOUT = 60000; +const VM_ACTIONS_TIMEOUT = 90000; + +describe('Test VM actions', () => { + const leakedResources = new Set(); + afterAll(async() => { + removeLeakedResources(leakedResources); + }); + + describe('Test VM list view kebab actions', () => { + const vmName = `vm-list-view-actions-${testName}`; + beforeAll(async() => { + testVM.metadata.name = vmName; + execSync(`echo '${JSON.stringify(testVM)}' | kubectl create -f -`); + leakedResources.add(JSON.stringify({name: vmName, namespace: testName, kind: 'vm'})); + }); + + // Workaround for https://github.com/kubevirt/web-ui/issues/177, remove when resolved + afterEach(async() => await browser.sleep(1000)); + + it('Navigates to VMs', async() => { + await browser.get(`${appHost}/k8s/all-namespaces/virtualmachines`); + await isLoaded(); + await filterForName(vmName); + await resourceRowsPresent(); + }); + + it('Starts VM', async() => { + await listViewAction(vmName)('Start'); + await browser.wait(until.textToBePresentInElement(listViewVMmStatus(vmName), 'Running'), VM_BOOTUP_TIMEOUT); + }); + + it('Restarts VM', async() => { + await listViewAction(vmName)('Restart'); + await browser.wait(until.textToBePresentInElement(listViewVMmStatus(vmName), 'Starting'), VM_BOOTUP_TIMEOUT); + await browser.wait(until.textToBePresentInElement(listViewVMmStatus(vmName), 'Running'), VM_BOOTUP_TIMEOUT); + }, VM_ACTIONS_TIMEOUT); + + it('Stops VM', async() => { + await listViewAction(vmName)('Stop'); + await browser.wait(until.textToBePresentInElement(listViewVMmStatus(vmName), 'Off'), 10000); + }); + + it('Deletes VM', async() => { + await listViewAction(vmName)('Delete'); + await browser.wait(until.not(until.presenceOf(listViewVMmStatus(vmName)))); + leakedResources.delete(JSON.stringify({name: vmName, namespace: testName, kind: 'vm'})); + }); + }); + + describe('Test VM detail view kebab actions', () => { + const vmName = `vm-detail-view-actions-${testName}`; + beforeAll(async() => { + testVM.metadata.name = vmName; + execSync(`echo '${JSON.stringify(testVM)}' | kubectl create -f -`); + leakedResources.add(JSON.stringify({name: vmName, namespace: testName, kind: 'vm'})); + }); + + it('Navigates to VMs detail page', async() => { + await browser.get(`${appHost}/k8s/all-namespaces/virtualmachines/${vmName}`); + await isLoaded(); + }); + + it('Starts VM', async() => { + await detailViewAction('Start'); + await browser.wait(until.textToBePresentInElement(detailViewVMmStatus, 'Running'), VM_BOOTUP_TIMEOUT); + }); + + it('Restarts VM', async() => { + await detailViewAction('Restart'); + await browser.wait(until.textToBePresentInElement(detailViewVMmStatus, 'Starting'), VM_BOOTUP_TIMEOUT); + await browser.wait(until.textToBePresentInElement(detailViewVMmStatus, 'Running'), VM_BOOTUP_TIMEOUT); + }, VM_ACTIONS_TIMEOUT); + + it('Stops VM', async() => { + await detailViewAction('Stop'); + await browser.wait(until.textToBePresentInElement(detailViewVMmStatus, 'Off'), 10000); + }); + + it('Deletes VM', async() => { + await detailViewAction('Delete'); + leakedResources.delete(JSON.stringify({name: vmName, namespace: testName, kind: 'vm'})); + }); + }); +}); diff --git a/frontend/integration-tests/tests/performance.scenario.ts b/frontend/integration-tests/tests/performance.scenario.ts index cba819b23d62..4f5f5de784dc 100644 --- a/frontend/integration-tests/tests/performance.scenario.ts +++ b/frontend/integration-tests/tests/performance.scenario.ts @@ -20,6 +20,7 @@ const chunkedRoutes = OrderedMap() .set('cron-job', {section: 'Workloads', name: 'Cron Jobs'}) .set('configmap', {section: 'Workloads', name: 'Config Maps'}) .set('hpa', {section: 'Workloads', name: 'HPAs'}) + .set('virtual-machine', {section: 'Workloads', name: 'Virtual Machines'}) .set('service', {section: 'Networking', name: 'Services'}) .set('persistent-volume', {section: 'Storage', name: 'Persistent Volumes'}) .set('persistent-volume-claim', {section: 'Storage', name: 'Persistent Volume Claims'}) diff --git a/frontend/integration-tests/views/crud.view.ts b/frontend/integration-tests/views/crud.view.ts index 8d11e642d73c..ee3f13458ff2 100644 --- a/frontend/integration-tests/views/crud.view.ts +++ b/frontend/integration-tests/views/crud.view.ts @@ -13,6 +13,8 @@ export const saveChangesBtn = $('#save-changes'); export const reloadBtn = $('#reload-object'); export const cancelBtn = $('#cancel'); +export const confirmAction = () => browser.wait(until.presenceOf($('#confirm-action'))).then(() => $('#confirm-action').click()); + /** * Returns a promise that resolves after the loading spinner is not present. */ diff --git a/frontend/integration-tests/views/kubevirt/vm.actions.view.ts b/frontend/integration-tests/views/kubevirt/vm.actions.view.ts new file mode 100644 index 000000000000..f2c3ee5939b3 --- /dev/null +++ b/frontend/integration-tests/views/kubevirt/vm.actions.view.ts @@ -0,0 +1,40 @@ +import { $, $$, browser, ExpectedConditions as until } from 'protractor'; +import { rowForName, confirmAction } from '../crud.view'; + +export const detailViewVMmStatus = $('#details-column-1 .kubevirt-vm-status__link'); +export const listViewVMmStatus = (name: string) => rowForName(name).$('.kubevirt-vm-status__link'); + +const listViewKebabDropdown = '.co-kebab__button'; +const listViewKebabDropdownMenu = '.co-kebab__dropdown'; +const detailViewDropdown = '.co-m-nav-title button'; +const detailViewDropdownMenu = '.dropdown-menu-right'; + +/** + * Selects option link from given dropdown element. + */ +const selectDropdownItem = (getActionsDropdown, getActionsDropdownMenu) => async(action) => { + await getActionsDropdown().click(); + await getActionsDropdownMenu().$$('a').filter(link => link.getText().then(text => text.startsWith(action))).first().click(); +}; + +/** + * Performs action for VM via list view kebab menu. + */ +export const listViewAction = (name) => async(action) => { + const getActionsDropdown = () => rowForName(name).$$(listViewKebabDropdown).first(); + const getActionsDropdownMenu = () => rowForName(name).$(listViewKebabDropdownMenu); + await selectDropdownItem(getActionsDropdown, getActionsDropdownMenu)(action); + await confirmAction(); + await browser.wait(until.not(until.presenceOf(rowForName(name).$(listViewKebabDropdownMenu)))); +}; + +/** + * Performs action for VM on its detail page. + */ +export const detailViewAction = async(action) => { + const getActionsDropdown = () => $$(detailViewDropdown).first(); + const getActionsDropdownMenu = () => $(detailViewDropdownMenu); + await selectDropdownItem(getActionsDropdown, getActionsDropdownMenu)(action); + await confirmAction(); + await browser.wait(until.not(until.presenceOf($(detailViewDropdownMenu)))); +}; diff --git a/frontend/package.json b/frontend/package.json index 08ead58ee1d9..05d2367c2bef 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,7 @@ "test-gui-tap": "TAP=true yarn run test-gui", "test-gui-openshift": "yarn run test-suite --suite crud --params.openshift true", "test-gui": "yarn run test-suite --suite all", - "test-suite": "ts-node -O '{\"module\":\"commonjs\"}' ./node_modules/.bin/protractor integration-tests/protractor.conf.ts", + "test-suite": "ts-node -O '{\"module\":\"commonjs\"}' -r ./integration-tests/preload.ts ./node_modules/.bin/protractor integration-tests/protractor.conf.ts", "analyze": "NODE_ENV=production ts-node -O '{\"module\":\"commonjs\"}' ./node_modules/.bin/webpack --mode=production --profile --json | awk '{if(NR>2)print}' > public/dist/stats.json && ts-node -O '{\"module\":\"commonjs\"}' ./node_modules/.bin/webpack-bundle-analyzer --mode static -r public/dist/report.html public/dist/stats.json" }, "jest": { @@ -65,6 +65,7 @@ "immutable": "3.x", "js-base64": "^2.4.5", "js-yaml": "3.x", + "kubevirt-web-ui-components": "0.1.18", "lodash-es": "4.x", "murmurhash-js": "1.0.x", "openshift-logos-icon": "1.7.1", @@ -134,7 +135,9 @@ "jasmine-reporters": "2.x", "jest": "21.x", "jest-cli": "21.x", + "lodash": "4.x", "mini-css-extract-plugin": "0.4.x", + "module-alias": "^2.1.0", "node-sass": "4.8.x", "protractor": "5.4.x", "protractor-fail-fast": "3.x", diff --git a/frontend/public/components/factory/details.tsx b/frontend/public/components/factory/details.tsx index a8765b402fde..675950a22547 100644 --- a/frontend/public/components/factory/details.tsx +++ b/frontend/public/components/factory/details.tsx @@ -4,20 +4,11 @@ import * as React from 'react'; import { match } from 'react-router-dom'; import * as _ from 'lodash-es'; -import { Firehose, HorizontalNav, PageHeading } from '../utils'; -import { K8sResourceKindReference, K8sResourceKind, Selector } from '../../module/k8s'; +import { Firehose, FirehoseResource, HorizontalNav, PageHeading } from '../utils'; +import { K8sResourceKindReference, K8sResourceKind } from '../../module/k8s'; import { withFallback } from '../utils/error-boundary'; import { ErrorBoundaryFallback } from '../error'; -export type FirehoseResource = { - kind: K8sResourceKindReference; - name?: string; - namespace: string; - isList?: boolean; - selector?: Selector; - prop: string; -}; - export const DetailsPage = withFallback((props) => fuzzy(_.toLower(a), _.toLower(b)); // TODO: Having list filters here is undocumented, stringly-typed, and non-obvious. We can change that @@ -185,6 +187,15 @@ const listFilters = { const status = getClusterOperatorStatus(operator); return statuses.selected.has(status) || !_.includes(statuses.all, status); }, + + 'vm-status': (statuses, vm) => { + if (!statuses || !statuses.selected || !statuses.selected.size) { + return true; + } + + const status = getVMStatus(vm); + return statuses.selected.has(status) || !_.includes(statuses.all, status); + }, }; const getFilteredRows = (_filters, objects) => { diff --git a/frontend/public/components/nav.jsx b/frontend/public/components/nav.jsx index fd9d87cc9960..0731e725517a 100644 --- a/frontend/public/components/nav.jsx +++ b/frontend/public/components/nav.jsx @@ -22,6 +22,7 @@ import { MachineSetModel, PackageManifestModel, SubscriptionModel, + VirtualMachineModel, } from '../models'; import { referenceForModel } from '../module/k8s'; @@ -338,6 +339,7 @@ export const Navigation = ({ isNavOpen, onNavSelect }) => { + diff --git a/frontend/public/components/overview/index.tsx b/frontend/public/components/overview/index.tsx index 386d4f80631c..6d7a52bf467d 100644 --- a/frontend/public/components/overview/index.tsx +++ b/frontend/public/components/overview/index.tsx @@ -12,6 +12,8 @@ import { Toolbar, EmptyState } from 'patternfly-react'; import { coFetchJSON } from '../../co-fetch'; import { getBuildNumber } from '../../module/k8s/builds'; +import { referenceForModel } from '../../module/k8s/k8s'; +import { FLAGS, featureReducerName } from '../../features'; import { prometheusTenancyBasePath } from '../graphs'; import { TextFilter } from '../factory'; import { UIActions, formatNamespacedRouteForResource } from '../../ui/ui-actions'; @@ -33,6 +35,7 @@ import { ReplicationControllerModel, ReplicaSetModel, StatefulSetModel, + VirtualMachineModel, } from '../../models'; import { ActionsMenu, @@ -463,6 +466,7 @@ class OverviewMainContent_ extends React.Component { + const obj = { + ...vm, + kind: referenceForModel(VirtualMachineModel), + }; + const pods = this.getPodsForResource(vm); + const services = this.getServicesForResource(vm); + const routes = this.getRoutesForServices(services); + // TODO: const status = ; + return { + obj, + pods, + routes, + services, + }; + }); + } + createOverviewData(): void { const {loaded, updateResources} = this.props; @@ -878,6 +903,7 @@ class OverviewMainContent_ extends React.Component(mainContentStateToProps, mainContentDispatchToProps)(OverviewMainContent_); -const overviewStateToProps = ({UI}): OverviewPropsFromState => { - const selectedUID = UI.getIn(['overview', 'selectedUID']); - const resources = UI.getIn(['overview', 'resources']); +const overviewStateToProps = (state): OverviewPropsFromState => { + const selectedUID = state.UI.getIn(['overview', 'selectedUID']); + const resources = state.UI.getIn(['overview', 'resources']); const selectedItem = !!resources && resources.get(selectedUID); - const selectedView = UI.getIn(['overview', 'selectedView'], View.Resources); - return { selectedItem, selectedView }; + const selectedView = state.UI.getIn(['overview', 'selectedView'], View.Resources); + const kubevirtFlag = state[featureReducerName].get(FLAGS.KUBEVIRT); + return { selectedItem, selectedView, kubevirtFlag }; }; const overviewDispatchToProps = (dispatch) => { @@ -955,9 +982,10 @@ const overviewDispatchToProps = (dispatch) => { }; }; -const Overview_: React.SFC = (({mock, namespace, selectedItem, selectedView, title, dismissDetails}) => { +const Overview_: React.SFC = (({mock, namespace, selectedItem, selectedView, title, dismissDetails, kubevirtFlag}) => { const sidebarOpen = !_.isEmpty(selectedItem) && selectedView !== View.Dashboard; const className = classnames('overview', {'overview--sidebar-shown': sidebarOpen}); + // TODO: Update resources for native Kubernetes clusters. const resources = [ { @@ -1032,6 +1060,14 @@ const Overview_: React.SFC = (({mock, namespace, selectedItem, se namespace, prop: 'statefulSets', }, + ...(kubevirtFlag && [ + { + isList: true, + kind: referenceForModel(VirtualMachineModel), + namespace, + prop: 'virtualMachines', + }, + ] || []), ]; return
@@ -1115,7 +1151,7 @@ export type BuildConfigOverviewItem = K8sResourceKind & { export type OverviewItem = { alerts?: OverviewItemAlerts; - buildConfigs: BuildConfigOverviewItem[]; + buildConfigs?: BuildConfigOverviewItem[]; current?: PodControllerOverviewItem; isRollingOut?: boolean; obj: K8sResourceKind; @@ -1197,6 +1233,7 @@ type OverviewMainContentOwnProps = { selectedItem: OverviewItem; statefulSets?: FirehoseList; title?: string; + virtualMachines?: FirehoseList; }; type OverviewMainContentProps = OverviewMainContentPropsFromState & OverviewMainContentPropsFromDispatch & OverviewMainContentOwnProps; @@ -1214,6 +1251,7 @@ type OverviewMainContentState = { type OverviewPropsFromState = { selectedItem: any; selectedView: View; + kubevirtFlag?: boolean; }; type OverviewPropsFromDispatch = { diff --git a/frontend/public/components/overview/resource-overview-pages.tsx b/frontend/public/components/overview/resource-overview-pages.tsx index be3d741e2338..8f9fd538bfe1 100644 --- a/frontend/public/components/overview/resource-overview-pages.tsx +++ b/frontend/public/components/overview/resource-overview-pages.tsx @@ -11,11 +11,13 @@ import { DeploymentConfigModel, StatefulSetModel, PodModel, + VirtualMachineModel, } from '../../models'; export const resourceOverviewPages = ImmutableMap Promise>>() - .set(referenceForModel(DaemonSetModel), () => import('./daemon-set-overview' /* webpackChunkNmae: "daemon-set"*/).then(m => m.DaemonSetOverview)) - .set(referenceForModel(DeploymentModel), () => import('./deployment-overview' /* webpackChunkNmae: "deployment"*/).then(m => m.DeploymentOverviewPage)) - .set(referenceForModel(DeploymentConfigModel), () => import('./deployment-config-overview' /* webpackChunkNmae: "deployment-config"*/).then(m => m.DeploymentConfigOverviewPage)) + .set(referenceForModel(DaemonSetModel), () => import('./daemon-set-overview' /* webpackChunkName: "daemon-set" */).then(m => m.DaemonSetOverview)) + .set(referenceForModel(DeploymentModel), () => import('./deployment-overview' /* webpackChunkName: "deployment" */).then(m => m.DeploymentOverviewPage)) + .set(referenceForModel(DeploymentConfigModel), () => import('./deployment-config-overview' /* webpackChunkName: "deployment-config" */).then(m => m.DeploymentConfigOverviewPage)) + .set(referenceForModel(StatefulSetModel), () => import('./stateful-set-overview' /* webpackChunkName: "stateful-set" */).then(m => m.StatefulSetOverview)) .set(referenceForModel(PodModel), () => import('./pod-overview' /* webpackChunkNmae: "pod"*/).then(m => m.PodOverviewPage)) - .set(referenceForModel(StatefulSetModel), () => import('./stateful-set-overview' /* webpackChunkNmae: "stateful-set"*/).then(m => m.StatefulSetOverview)); + .set(referenceForModel(VirtualMachineModel), () => import('../../extend/kubevirt/components/vm-overview' /* webpackChunkName: "virtual-machine" */).then(m => m.VirtualMachineOverviewPage)); diff --git a/frontend/public/components/resource-pages.ts b/frontend/public/components/resource-pages.ts index da24f3bad1e9..70d87c817046 100644 --- a/frontend/public/components/resource-pages.ts +++ b/frontend/public/components/resource-pages.ts @@ -59,6 +59,7 @@ import { StorageClassModel, SubscriptionModel, PackageManifestModel, + VirtualMachineModel, } from '../models'; export const resourceDetailPages = ImmutableMap Promise>>() @@ -113,7 +114,8 @@ export const resourceDetailPages = ImmutableMap .set(referenceForModel(SubscriptionModel), () => import('./operator-lifecycle-manager/subscription' /* webpackChunkName: "subscription" */).then(m => m.SubscriptionDetailsPage)) .set(referenceForModel(InstallPlanModel), () => import('./operator-lifecycle-manager/install-plan' /* webpackChunkName: "install-plan" */).then(m => m.InstallPlanDetailsPage)) .set(referenceForModel(ClusterOperatorModel), () => import('./cluster-settings/cluster-operator' /* webpackChunkName: "cluster-operator" */).then(m => m.ClusterOperatorDetailsPage)) - .set(referenceForModel(OAuthModel), () => import('./cluster-settings/oauth' /* webpackChunkName: "oauth" */).then(m => m.OAuthDetailsPage)); + .set(referenceForModel(OAuthModel), () => import('./cluster-settings/oauth' /* webpackChunkName: "oauth" */).then(m => m.OAuthDetailsPage)) + .set(referenceForModel(VirtualMachineModel), () => import('../extend/kubevirt/components/vm-details' /* webpackChunkName: "virtual-machine" */).then(m => m.VirtualMachinesDetailsPage)); export const resourceListPages = ImmutableMap Promise>>() .set(referenceForModel(ClusterServiceClassModel), () => import('./cluster-service-class' /* webpackChunkName: "cluster-service-class" */).then(m => m.ClusterServiceClassPage)) @@ -166,4 +168,5 @@ export const resourceListPages = ImmutableMap P .set(referenceForModel(PackageManifestModel), () => import('./operator-lifecycle-manager/package-manifest' /* webpackChunkName: "package-manifest" */).then(m => m.PackageManifestsPage)) .set(referenceForModel(SubscriptionModel), () => import('./operator-lifecycle-manager/subscription' /* webpackChunkName: "subscription" */).then(m => m.SubscriptionsPage)) .set(referenceForModel(InstallPlanModel), () => import('./operator-lifecycle-manager/install-plan' /* webpackChunkName: "install-plan" */).then(m => m.InstallPlansPage)) - .set(referenceForModel(ClusterOperatorModel), () => import('./cluster-settings/cluster-operator' /* webpackChunkName: "cluster-operator" */).then(m => m.ClusterOperatorPage)); + .set(referenceForModel(ClusterOperatorModel), () => import('./cluster-settings/cluster-operator' /* webpackChunkName: "cluster-operator" */).then(m => m.ClusterOperatorPage)) + .set(referenceForModel(VirtualMachineModel), () => import('../extend/kubevirt/components/vm-list' /* webpackChunkName: "virtual-machine" */).then(m => m.VirtualMachinesPage)); diff --git a/frontend/public/components/software-details.jsx b/frontend/public/components/software-details.jsx index fe278a69978e..c89f20dfcf6a 100644 --- a/frontend/public/components/software-details.jsx +++ b/frontend/public/components/software-details.jsx @@ -2,9 +2,11 @@ import * as React from 'react'; import * as classNames from 'classnames'; import { k8sVersion } from '../module/status'; +import { FLAGS, connectToFlags, flagPending } from '../features'; import { SafetyFirst } from './safety-first'; import { LoadingInline } from './utils'; +import { getKubeVirtVersion } from '../extend/kubevirt/module/status'; const StatusIconRow = ({state, text}) => { const iconClasses = { @@ -56,27 +58,55 @@ const SoftwareDetailRow = ({title, detail, text, children}) => {
; }; -export class SoftwareDetails extends SafetyFirst { - constructor(props) { - super(props); - this.state = { - kubernetesVersion: null, - }; - } +export const SoftwareDetails = connectToFlags(FLAGS.KUBEVIRT)( + class SoftwareDetails extends SafetyFirst { + constructor(props) { + super(props); + this.state = { + kubernetesVersion: null, + kubevirtVersion: null, + }; + } - componentDidMount() { - super.componentDidMount(); - this._checkKubernetesVersion(); - } + componentDidMount() { + super.componentDidMount(); + this._checkKubernetesVersion(); + this._checkKubeVirtVersion(); + } - _checkKubernetesVersion() { - k8sVersion() - .then((data) => this.setState({kubernetesVersion: data.gitVersion})) - .catch(() => this.setState({kubernetesVersion: 'unknown'})); - } + componentDidUpdate(prevProps) { + if (prevProps.flags[FLAGS.KUBEVIRT] !== this.props.flags[FLAGS.KUBEVIRT]) { + this._checkKubeVirtVersion(); + } + } + + _checkKubernetesVersion() { + k8sVersion() + .then((data) => this.setState({kubernetesVersion: data.gitVersion})) + .catch(() => this.setState({kubernetesVersion: 'unknown'})); + } + + _checkKubeVirtVersion() { + const kubevirtFlag = this.props.flags[FLAGS.KUBEVIRT]; + if (kubevirtFlag) { + getKubeVirtVersion() + .then((data) => this.setState({kubevirtVersion: data.gitVersion})) + .catch(() => this.setState({kubevirtVersion: 'unknown'})); + } + } + + render() { + const {kubernetesVersion, kubevirtVersion} = this.state; + const kubevirtFlag = this.props.flags[FLAGS.KUBEVIRT]; + + if (flagPending(kubevirtFlag)) { + return null; + } - render() { - const {kubernetesVersion} = this.state; - return ; + return + + {kubevirtFlag && } + ; + } } -} +); diff --git a/frontend/public/components/utils/index.tsx b/frontend/public/components/utils/index.tsx index e4fad8f9aa7f..511d6e3d7daf 100644 --- a/frontend/public/components/utils/index.tsx +++ b/frontend/public/components/utils/index.tsx @@ -47,6 +47,9 @@ export * from './k8s-watcher'; export * from './workload-pause'; export * from './list-dropdown'; export * from './status-icon'; + +import { K8sResourceKind, K8sResourceKindReference, Selector } from '../../module/k8s'; + /* Add the enum for NameValueEditorPair here and not in its namesake file because the editor should always be loaded asynchronously in order not to bloat the vendor file. The enum reference into the editor @@ -72,3 +75,17 @@ export const enum EnvType { ENV = 0, ENV_FROM = 1 } + +// Firehose component prop types +export type FirehoseResource = { + kind: K8sResourceKindReference | K8sResourceKind; + name?: string; + namespace?: string; + namespaced?: boolean; + prop?: string; + selector?: Selector; + fieldSelector?: string; + className?: string; + isList?: boolean; + optional?: boolean; +}; diff --git a/frontend/public/extend/kubevirt/OWNERS b/frontend/public/extend/kubevirt/OWNERS new file mode 100644 index 000000000000..b385808bee8f --- /dev/null +++ b/frontend/public/extend/kubevirt/OWNERS @@ -0,0 +1,9 @@ +reviewers: + - mareklibra + - pcbailey + - rawagner + - suomiy + - vojtechszocs +approvers: + - mareklibra + - rawagner diff --git a/frontend/public/extend/kubevirt/README.md b/frontend/public/extend/kubevirt/README.md new file mode 100644 index 000000000000..6bcfe6c8579a --- /dev/null +++ b/frontend/public/extend/kubevirt/README.md @@ -0,0 +1,29 @@ +# KubeVirt extension + +This directory contains [KubeVirt](https://kubevirt.io/) extension of Console UI. + +## Components + +KubeVirt components generally follow Console UI dependency requirements, including +[PatternFly 3](https://github.com/patternfly/patternfly) and the corresponding +[PatternFly-React](https://github.com/patternfly/patternfly-react) implementation. + +KubeVirt components themselves are maintained in a separate repository and used via +[kubevirt-web-ui-components](https://www.npmjs.com/package/kubevirt-web-ui-components) +npm package. + +## Integration + +All KubeVirt related integration code lives in `frontend/public/extend/kubevirt`. +This code is imported and used in various modules of the frontend application through +minimum necessary changes. + +### Styling + +The main `style.scss` stylesheet is modified to import KubeVirt related styles which +generally follow the [BEM](http://getbem.com/) methodology along with the `kubevirt` +prefix to namespace all KubeVirt component styling. + +## Dependencies + +KubeVirt related dependencies are simply added to the frontend `package.json` file. diff --git a/frontend/public/extend/kubevirt/_style.scss b/frontend/public/extend/kubevirt/_style.scss new file mode 100644 index 000000000000..559dc1db2f1b --- /dev/null +++ b/frontend/public/extend/kubevirt/_style.scss @@ -0,0 +1 @@ +@import '~kubevirt-web-ui-components/dist/sass/components'; diff --git a/frontend/public/extend/kubevirt/components/modals/index.ts b/frontend/public/extend/kubevirt/components/modals/index.ts new file mode 100644 index 000000000000..8edbfc3762e9 --- /dev/null +++ b/frontend/public/extend/kubevirt/components/modals/index.ts @@ -0,0 +1,4 @@ +// This module utilizes dynamic `import()` to enable lazy-loading for each modal instead of including them in the main bundle. + +export const startStopVMModal = (props) => import('./start-stop-vm-modal' /* webpackChunkName: "start-stop-vm-modal" */) + .then(m => m.startStopVMModal(props)); diff --git a/frontend/public/extend/kubevirt/components/modals/start-stop-vm-modal.tsx b/frontend/public/extend/kubevirt/components/modals/start-stop-vm-modal.tsx new file mode 100644 index 000000000000..7509389dc903 --- /dev/null +++ b/frontend/public/extend/kubevirt/components/modals/start-stop-vm-modal.tsx @@ -0,0 +1,63 @@ +/* eslint-disable no-undef, no-unused-vars */ + +import * as React from 'react'; +import { getPxeBootPatch } from 'kubevirt-web-ui-components'; + +import { PromiseComponent } from '../../../../components/utils'; +import { createModalLauncher, ModalTitle, ModalBody, ModalSubmitFooter, ModalComponentProps } from '../../../../components/factory/modal'; +import { k8sPatch, K8sKind, K8sResourceKind } from '../../../../module/k8s'; + +class StartStopVMModal extends PromiseComponent { + readonly props: StartStopVMModalProps; + readonly state: StartStopVMModalState; + + _submit = (event) => { + event.preventDefault(); + const { resource: vm, start, kind, close } = this.props; + const patch = []; + + // handle PXE boot + if (start) { + const pxePatch = getPxeBootPatch(vm); + patch.push(...pxePatch); + } + + patch.push({ + op: 'replace', + path: '/spec/running', + value: start, + }); + + const promise = k8sPatch(kind, vm, patch); + this.handlePromise(promise).then(close); + } + + render() { + const { resource: vm, start, cancel } = this.props; + const { errorMessage, inProgress } = this.state; + const action = start ? 'Start' : 'Stop'; + return
+ {action} Virtual Machine + +
+ Are you sure you want to {action} {vm.metadata.name} + {' '} in namespace {vm.metadata.namespace}? +
+
+ + ; + } +} + +export const startStopVMModal = createModalLauncher(StartStopVMModal); + +type StartStopVMModalProps = { + start: boolean; + kind: K8sKind; + resource: K8sResourceKind; +} & ModalComponentProps; + +type StartStopVMModalState = { + inProgress: boolean; + errorMessage: string; +}; diff --git a/frontend/public/extend/kubevirt/components/vm-details.tsx b/frontend/public/extend/kubevirt/components/vm-details.tsx new file mode 100644 index 000000000000..72883b5f4227 --- /dev/null +++ b/frontend/public/extend/kubevirt/components/vm-details.tsx @@ -0,0 +1,181 @@ +/* eslint-disable no-undef, no-unused-vars */ + +import * as React from 'react'; +import * as _ from 'lodash-es'; +import * as classNames from 'classnames'; +import { match } from 'react-router-dom'; +import { TEMPLATE_OS_LABEL } from 'kubevirt-web-ui-components'; + +import { ResourceEventStream } from '../../../components/events'; +import { DetailsPage } from '../../../components/factory'; +import { Firehose, FirehoseResource, SectionHeading, ResourceLink, ResourceSummary, navFactory } from '../../../components/utils'; +import { breadcrumbsForOwnerRefs } from '../../../components/utils/breadcrumbs'; +import { VirtualMachineModel, VirtualMachineInstanceModel, PodModel } from '../../../models'; +import { referenceForModel } from '../../../module/k8s'; + +import { menuActions, StateColumn } from './vm-list'; +import { getLabelMatcher, findVMI, findPod, getFlattenForKind } from '../module/k8s/vms'; +import * as s from '../strings'; + +const FirehoseResourceLink = (props) => { + const { loaded, loadError, flatten, filter, resources } = props; + if (loaded && !loadError) { + const data = flatten(resources); + if (data) { + const resource = filter ? filter(data) : data[0]; + if (resource) { + const { name, namespace } = resource.metadata; + const kind = resource.kind || PodModel.kind; + return ; + } + } + } + return {s.notAvailable}; +}; + +const VMStatus = ({ vm }) => { + const podResource: FirehoseResource = { + kind: PodModel.kind, + prop: PodModel.kind, + namespaced: true, + namespace: vm.metadata.namespace, + selector: { matchLabels: getLabelMatcher(vm) }, + isList: true, + }; + + return
+
State
+
+ +
+
Pod
+
+ + findPod(data, vm.metadata.name)} /> + +
+
; +}; + +class VMResourceConfiguration extends React.Component { + _getCpu = (resource) => { + return this._getFromDomain(resource, ['cpu', 'cores']); + } + + _getMemory = (resource) => { + return this._getFromDomain(resource, ['resources', 'requests', 'memory']); + } + + _getFromDomain = (resource, path) => { + const domain = ['spec', 'domain']; + if (resource.kind === VirtualMachineModel.kind) { + domain.unshift('spec', 'template'); + } + domain.push(...path); + return _.get(resource, domain); + } + + _getVMConfiguration = () => { + const configuration: any = {}; + const { flatten, resources, filter, vm } = this.props; + const data = flatten(resources); + const vmi = filter(data); + if (vmi) { + configuration.cpu = this._getCpu(vmi); + configuration.memory = this._getMemory(vmi); + } else { + configuration.cpu = this._getCpu(vm); + configuration.memory = this._getMemory(vm); + } + configuration.os = _.get(vm, ['metadata', 'annotations', TEMPLATE_OS_LABEL]); + return configuration; + } + + render() { + const configuration = this._getVMConfiguration(); + const { memory, cpu, os } = configuration; + return
+
Memory
+
{memory || s.notAvailable}
+
CPU
+
{cpu || s.notAvailable}
+
Operating System
+
{os || s.notAvailable}
+
; + } +} + +const VMDetails = ({ obj: vm }) => { + const vmiResource: FirehoseResource = { + kind: referenceForModel(VirtualMachineInstanceModel), + prop: VirtualMachineInstanceModel.kind, + name: vm.metadata.name, + namespaced: true, + namespace: vm.metadata.namespace, + selector: { matchLabels: getLabelMatcher(vm) }, + isList: true, + }; + + return +
+ +
+
+ +
+
+ +
+
+ + findVMI(data, vm.metadata.name)} + vm={vm} /> + +
+
+
+
; +}; + +const VMIEvents = ({ obj: vm }) => { + const vmi = { + kind: referenceForModel(VirtualMachineInstanceModel), + metadata: { + name: vm.metadata.name, + namespace: vm.metadata.namespace, + }, + }; + return ; +}; + +export const VirtualMachinesDetailsPage: React.SFC = (props) => ( + breadcrumbsForOwnerRefs(obj).concat({ + name: 'Virtual Machine Details', + path: props.match.url, + })} + pages={[ + navFactory.details(VMDetails), + navFactory.editYaml(), + navFactory.events(VMIEvents), + ]} + menuActions={menuActions} + /> +); + +type VMResourceConfigurationProps = { + flatten: (resources: FirehoseResource[]) => {}; + resources?: FirehoseResource[]; + filter: (data: {}) => {}; + vm: {}; +}; + +type VirtualMachinesDetailsPageProps = { + match: match; +}; diff --git a/frontend/public/extend/kubevirt/components/vm-list.tsx b/frontend/public/extend/kubevirt/components/vm-list.tsx new file mode 100644 index 000000000000..61a701d9bae2 --- /dev/null +++ b/frontend/public/extend/kubevirt/components/vm-list.tsx @@ -0,0 +1,98 @@ +/* eslint-disable no-undef, no-unused-vars */ + +import * as React from 'react'; +import * as _ from 'lodash-es'; + +import { ListHeader, ColHead, List, ListPage, ResourceRow } from '../../../components/factory'; +import { ResourceLink, ResourceKebab, Kebab } from '../../../components/utils'; +import { VirtualMachineModel, NamespaceModel } from '../../../models'; +import { referenceForModel } from '../../../module/k8s'; + +import { startStopVMModal } from './modals'; +import { getVMStatus } from '../module/k8s/vms'; +import * as s from '../strings'; + +const VMHeader = (props) => + Name + Namespace + State +; + +const getAction = (vm) => { + return _.get(vm, 'spec.running', false) ? 'Stop Virtual Machine' : 'Start Virtual Machine'; +}; + +const menuActionStart = (kind, vm) => ({ + label: getAction(vm), + callback: () => startStopVMModal({ + kind, + resource: vm, + start: !_.get(vm, 'spec.running', false), + }), +}); + +export const menuActions = [menuActionStart, Kebab.factory.Edit, Kebab.factory.Delete]; + +export const StateColumn = ({ vm }) => { + const value = getVMStatus(vm); + return value && {value} || {s.notAvailable}; +}; + +export const PhaseColumn = (props) => { + const { loaded, loadError, flatten, filter, resources } = props; + let value; + if (loaded && !loadError) { + const vmi = filter(flatten(resources)); + value = _.get(vmi, 'status.phase'); + } + return value || {s.notAvailable}; +}; + +const VMRow = ({ obj: vm }) => { + return +
+ +
+
+ +
+
+ +
+
+ +
+
; +}; + +const VMList = (props) => ; + +const filters = [{ + type: 'vm-status', + selected: ['Running', 'Stopped'], + reducer: getVMStatus, + items: [ + { id: 'Running', title: 'Running' }, + { id: 'Stopped', title: 'Stopped' }, + ], +}]; + +export const VirtualMachinesPage: React.SFC<{}> = (props) => ( + +); diff --git a/frontend/public/extend/kubevirt/components/vm-overview.tsx b/frontend/public/extend/kubevirt/components/vm-overview.tsx new file mode 100644 index 000000000000..7aabc1f20b8d --- /dev/null +++ b/frontend/public/extend/kubevirt/components/vm-overview.tsx @@ -0,0 +1,54 @@ +/* eslint-disable no-undef, no-unused-vars */ + +import * as React from 'react'; + +import { VirtualMachineModel } from '../../../models'; +import { ResourceSummary } from '../../../components/utils'; +import { OverviewItem } from '../../../components/overview'; +import { NetworkingOverview } from '../../../components/overview/networking-overview'; +import { ResourceOverviewDetails } from '../../../components/overview/resource-overview-details'; + +import { menuActions } from './vm-list'; + +const VirtualMachineOverviewDetails: React.SFC = ({ item }) => +
+
+ +
+
; + +const VirtualMachineResourcesTab: React.SFC = ({ item: { routes, services } }) => +
+ +
; + +const tabs = [ + { + name: 'Overview', + component: VirtualMachineOverviewDetails, + }, + { + name: 'Resources', + component: VirtualMachineResourcesTab, + }, +]; + +export const VirtualMachineOverviewPage: React.SFC = ({ item }) => + ; + +type VirtualMachineOverviewDetailsProps = { + item: OverviewItem; +}; + +type VirtualMachineResourcesTabProps = { + item: OverviewItem; +}; + +type VirtualMachineOverviewProps = { + item: OverviewItem; +}; diff --git a/frontend/public/extend/kubevirt/constants.ts b/frontend/public/extend/kubevirt/constants.ts new file mode 100644 index 000000000000..fac6b01b858b --- /dev/null +++ b/frontend/public/extend/kubevirt/constants.ts @@ -0,0 +1,3 @@ +export const kubevirtApiGroup = 'kubevirt.io'; +export const kubevirtApiSubresourceGroup = `subresources.${kubevirtApiGroup}`; +export const kubevirtApiVersion = 'v1alpha2'; diff --git a/frontend/public/extend/kubevirt/module/k8s/vms.ts b/frontend/public/extend/kubevirt/module/k8s/vms.ts new file mode 100644 index 000000000000..a160c9303ec1 --- /dev/null +++ b/frontend/public/extend/kubevirt/module/k8s/vms.ts @@ -0,0 +1,19 @@ +/* eslint-disable no-undef, no-unused-vars */ + +import * as _ from 'lodash-es'; + +export const getLabelMatcher = (vm) => _.get(vm, 'spec.template.metadata.labels'); + +export const findPod = (data, name) => { + const pods = data.filter(p => p.metadata.name.startsWith(`virt-launcher-${name}-`)); + const runningPod = pods.find(p => _.get(p, 'status.phase') === 'Running' || _.get(p, 'status.phase') === 'Pending'); + return runningPod || pods.find(p => _.get(p, 'status.phase') === 'Failed' || _.get(p, 'status.phase') === 'Unknown'); +}; + +export const findVMI = (data, name) => data.find(vmi => vmi.metadata.name === name); + +export const getFlattenForKind = (kind) => { + return resources => _.get(resources, [kind, 'data']); +}; + +export const getVMStatus = vm => _.get(vm, 'spec.running', false) ? 'Running' : 'Stopped'; diff --git a/frontend/public/extend/kubevirt/module/status.ts b/frontend/public/extend/kubevirt/module/status.ts new file mode 100644 index 000000000000..e0a609920db5 --- /dev/null +++ b/frontend/public/extend/kubevirt/module/status.ts @@ -0,0 +1,7 @@ +import { k8sBasePath } from '../../../module/k8s/k8s'; +import { coFetchJSON } from '../../../co-fetch'; + +import { kubevirtApiSubresourceGroup, kubevirtApiVersion } from '../constants'; + +export const getKubeVirtVersion = () => + coFetchJSON(`${k8sBasePath}/apis/${kubevirtApiSubresourceGroup}/${kubevirtApiVersion}/version`); diff --git a/frontend/public/extend/kubevirt/strings.ts b/frontend/public/extend/kubevirt/strings.ts new file mode 100644 index 000000000000..9f00eabe2d8a --- /dev/null +++ b/frontend/public/extend/kubevirt/strings.ts @@ -0,0 +1 @@ +export const notAvailable = 'Not available'; diff --git a/frontend/public/features.ts b/frontend/public/features.ts index d02e3d00874d..bde3fc50bc7b 100644 --- a/frontend/public/features.ts +++ b/frontend/public/features.ts @@ -13,6 +13,7 @@ import { OperatorSourceModel, PrometheusModel, SelfSubjectAccessReviewModel, + VirtualMachineModel, } from './models'; import { ClusterVersionKind } from './module/k8s'; import { k8sBasePath, referenceForModel } from './module/k8s/k8s'; @@ -42,6 +43,7 @@ import { UIActions } from './ui/ui-actions'; CLUSTER_API: false, CLUSTER_VERSION: false, MACHINE_CONFIG: false, + KUBEVIRT: false, */ export enum FLAGS { AUTH_ENABLED = 'AUTH_ENABLED', @@ -62,6 +64,7 @@ export enum FLAGS { CLUSTER_API = 'CLUSTER_API', CLUSTER_VERSION = 'CLUSTER_VERSION', MACHINE_CONFIG = 'MACHINE_CONFIG', + KUBEVIRT = 'KUBEVIRT', } export const DEFAULTS_ = _.mapValues(FLAGS, flag => flag === FLAGS.AUTH_ENABLED @@ -77,6 +80,7 @@ export const CRDs = { [referenceForModel(OperatorSourceModel)]: FLAGS.OPERATOR_HUB, [referenceForModel(MachineModel)]: FLAGS.CLUSTER_API, [referenceForModel(MachineConfigModel)]: FLAGS.MACHINE_CONFIG, + [referenceForModel(VirtualMachineModel)]: FLAGS.KUBEVIRT, }; const SET_FLAG = 'SET_FLAG'; diff --git a/frontend/public/models/index.ts b/frontend/public/models/index.ts index f5aa7bc52818..1779685f805f 100644 --- a/frontend/public/models/index.ts +++ b/frontend/public/models/index.ts @@ -1,6 +1,8 @@ // eslint-disable-next-line no-unused-vars import { K8sKind } from '../module/k8s'; +import { kubevirtApiGroup, kubevirtApiVersion } from '../extend/kubevirt/constants'; + export const CatalogSourceConfigModel: K8sKind = { kind: 'CatalogSourceConfig', label: 'CatalogSourceConfig', @@ -946,3 +948,47 @@ export const OAuthModel: K8sKind = { id: 'oauth', crd: true, }; + +// KubeVirt resources +// https://github.com/kubevirt/kubevirt +export const VirtualMachineModel: K8sKind = { + label: 'Virtual Machine', + labelPlural: 'Virtual Machines', + apiVersion: kubevirtApiVersion, + path: 'virtualmachines', + apiGroup: kubevirtApiGroup, + plural: 'virtualmachines', + abbr: 'VM', + namespaced: true, + kind: 'VirtualMachine', + id: 'virtualmachine', + crd: true, +}; + +export const VirtualMachineInstanceModel: K8sKind = { + label: 'Virtual Machine Instance', + labelPlural: 'Virtual Machine Instances', + apiVersion: kubevirtApiVersion, + path: 'virtualmachineinstances', + apiGroup: kubevirtApiGroup, + plural: 'virtualmachineinstances', + abbr: 'VMI', + namespaced: true, + kind: 'VirtualMachineInstance', + id: 'virtualmachineinstance', + crd: true, +}; + +export const VirtualMachineInstancePresetModel: K8sKind = { + label: 'Virtual Machine Instance Preset', + labelPlural: 'Virtual Machine Instance Presets', + apiVersion: kubevirtApiVersion, + path: 'virtualmachineinstancepresets', + apiGroup: kubevirtApiGroup, + plural: 'virtualmachineinstancepresets', + abbr: 'VMIP', + namespaced: true, + kind: 'VirtualMachineInstancePreset', + id: 'virtualmachineinstancepreset', + crd: true, +}; diff --git a/frontend/public/models/yaml-templates.ts b/frontend/public/models/yaml-templates.ts index 71622b182ec8..63277bfcba3d 100644 --- a/frontend/public/models/yaml-templates.ts +++ b/frontend/public/models/yaml-templates.ts @@ -801,4 +801,43 @@ spec: machineSelector: matchLabels: node-role.kubernetes.io/master: "" +`).setIn([referenceForModel(k8sModels.VirtualMachineModel), 'default'], ` +apiVersion: ${k8sModels.VirtualMachineModel.apiGroup}/${k8sModels.VirtualMachineModel.apiVersion} +kind: VirtualMachine +metadata: + name: example +spec: + running: false + template: + metadata: + labels: + kubevirt.io/size: small + spec: + domain: + devices: + disks: + - name: registrydisk + volumeName: registryvolume + disk: + bus: virtio + - name: cloudinitdisk + volumeName: cloudinitvolume + disk: + bus: virtio + interfaces: + - name: default + bridge: {} + resources: + requests: + memory: 64M + networks: + - name: default + pod: {} + volumes: + - name: registryvolume + registryDisk: + image: kubevirt/cirros-registry-disk-demo + - name: cloudinitvolume + cloudInitNoCloud: + userDataBase64: SGkuXG4= `); diff --git a/frontend/public/style.scss b/frontend/public/style.scss index 8ee6bc6a78d6..4c0cd9cf94b7 100644 --- a/frontend/public/style.scss +++ b/frontend/public/style.scss @@ -89,3 +89,6 @@ @import "components/storage-class-form"; @import "components/quota"; @import "components/cluster-settings/cluster-settings"; + +// KubeVirt styles +@import "extend/kubevirt/style"; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 669e8b52c2ac..6ac49e8ec7c7 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -86,6 +86,10 @@ version "0.8.2" resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-0.8.2.tgz#576ff7fb1230185b619a75d258cbc98f0867a8dc" +"@novnc/novnc@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@novnc/novnc/-/novnc-1.0.0.tgz#76b0e89e6f8738ca8154195baf5b8e6a80bc9105" + "@mapbox/geojson-area@0.2.2": version "0.2.2" resolved "https://registry.yarnpkg.com/@mapbox/geojson-area/-/geojson-area-0.2.2.tgz#18d7814aa36bf23fbbcc379f8e26a22927debf10" @@ -135,6 +139,16 @@ resolved "https://registry.yarnpkg.com/@patternfly/patternfly/-/patternfly-1.0.219.tgz#3fb57ca7ec88437d78cd84f6e9563979f1081469" integrity sha512-1HWy1T51G5KgqhmDV9FRXlaxHiQqS0e3vqS3EuAqrlszKh2TChMi1u6kxMGtp/m9VuDziuGiue3V/0+U1mKnRQ== +"@patternfly/react-console@1.x": + version "1.10.16" + resolved "https://registry.yarnpkg.com/@patternfly/react-console/-/react-console-1.10.16.tgz#7ef714fdd79c347732abbc33aa28608f9fbb0abc" + dependencies: + "@novnc/novnc" "^1.0.0" + blob-polyfill "^3.0.20180112" + classnames "^2.2.5" + file-saver "^1.3.8" + xterm "^3.3.0" + "@patternfly/react-core@2.4.1": version "2.4.1" resolved "https://registry.yarnpkg.com/@patternfly/react-core/-/react-core-2.4.1.tgz#3f2fedb160ed9451be5126b906bb8ec493146540" @@ -1148,6 +1162,10 @@ atob@~1.1.0: version "1.1.3" resolved "https://registry.yarnpkg.com/atob/-/atob-1.1.3.tgz#95f13629b12c3a51a5d215abdce2aa9f32f80773" +autobind-decorator@^2.1.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/autobind-decorator/-/autobind-decorator-2.4.0.tgz#ea9e1c98708cf3b5b356f7cf9f10f265ff18239c" + autoprefixer@^6.3.1: version "6.7.7" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-6.7.7.tgz#1dbd1c835658e35ce3f9984099db00585c782014" @@ -2020,6 +2038,10 @@ bl@^1.0.0: readable-stream "^2.3.5" safe-buffer "^5.1.1" +blob-polyfill@^3.0.20180112: + version "3.0.20180112" + resolved "https://registry.yarnpkg.com/blob-polyfill/-/blob-polyfill-3.0.20180112.tgz#7f7c3e235ae298867f8c22b429d33aa9a7cc65b5" + block-stream@*: version "0.0.9" resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" @@ -3753,6 +3775,15 @@ dnd-core@^2.6.0: lodash "^4.2.0" redux "^3.7.1" +dnd-core@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-4.0.5.tgz#3b83d138d0d5e265c73ec978dec5e1ed441dc665" + dependencies: + asap "^2.0.6" + invariant "^2.2.4" + lodash "^4.17.10" + redux "^4.0.0" + doctrine@1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa" @@ -4663,7 +4694,7 @@ file-loader@1.x: loader-utils "^1.0.2" schema-utils "^0.4.5" -file-saver@1.3.x: +file-saver@1.3.x, file-saver@^1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-1.3.8.tgz#e68a30c7cb044e2fb362b428469feb291c2e09d8" @@ -7382,6 +7413,18 @@ kind-of@^6.0.0, kind-of@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" +kubevirt-web-ui-components@0.1.18: + version "0.1.18" + resolved "https://registry.yarnpkg.com/kubevirt-web-ui-components/-/kubevirt-web-ui-components-0.1.18.tgz#59324d079235806342d15d18a5b4e3316e36c87a" + dependencies: + "@patternfly/react-console" "1.x" + classnames "2.x" + js-yaml "3.x" + lodash "4.x" + react-dnd "2.6.x" + react-dnd-html5-backend "5.0.x" + reactabular-dnd "8.16.x" + lazy-cache@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" @@ -7686,14 +7729,14 @@ lodash@3.10.0: version "3.10.0" resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.0.tgz#93d51c672828a4416a12af57220ba8a8737e2fbb" +lodash@4.x, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.2, lodash@^4.17.4: + version "4.17.11" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" + lodash@^4.0.0, lodash@^4.13.1, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.3, lodash@^4.17.5, lodash@^4.2.1, lodash@^4.3.0, lodash@~4.17.4: version "4.17.5" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.5.tgz#99a92d65c0272debe8c96b6057bc8fbfa3bed511" -lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.2, lodash@^4.17.4: - version "4.17.11" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" - lodash@^4.2.0: version "4.17.10" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.10.tgz#1b7793cf7259ea38fb3661d4d38b3260af8ae4e7" @@ -8153,6 +8196,10 @@ mkdirp@0.5.1, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkd dependencies: minimist "0.0.8" +module-alias@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/module-alias/-/module-alias-2.1.0.tgz#c36d4fd15f7f9d7112f62fa015385e7b65a286c1" + moment-timezone@^0.4.0, moment-timezone@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.4.1.tgz#81f598c3ad5e22cdad796b67ecd8d88d0f5baa06" @@ -10090,13 +10137,22 @@ react-diff-view@^1.8.1: lodash.mapvalues "^4.6.0" warning "^4.0.1" +react-dnd-html5-backend@5.0.x: + version "5.0.1" + resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-5.0.1.tgz#0b578d79c5c01317c70414c8d717f632b919d4f1" + dependencies: + autobind-decorator "^2.1.0" + dnd-core "^4.0.5" + lodash "^4.17.10" + shallowequal "^1.0.2" + react-dnd-html5-backend@^2.6.0: version "2.6.0" resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-2.6.0.tgz#590cd1cca78441bb274edd571fef4c0b16ddcf8e" dependencies: lodash "^4.2.0" -react-dnd@^2.6.0: +react-dnd@2.6.x, react-dnd@^2.6.0: version "2.6.0" resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-2.6.0.tgz#7fa25676cf827d58a891293e3c1ab59da002545a" dependencies: @@ -10308,6 +10364,10 @@ react@16.6.3: prop-types "^15.6.2" scheduler "^0.11.2" +reactabular-dnd@8.16.x: + version "8.16.0" + resolved "https://registry.yarnpkg.com/reactabular-dnd/-/reactabular-dnd-8.16.0.tgz#c66df1cfa08615978aa4eeb885747a1634dfc54a" + reactabular-table@^8.14.0: version "8.14.0" resolved "https://registry.yarnpkg.com/reactabular-table/-/reactabular-table-8.14.0.tgz#2ce05de47d499db91678fa9518505ea509bf3d7f" @@ -11374,6 +11434,10 @@ sharkdown@^0.1.0: stream-spigot "~2.1.2" through "~2.3.4" +shallowequal@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" + shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" @@ -13344,6 +13408,10 @@ xtend@~2.1.1: dependencies: object-keys "~0.4.0" +xterm@^3.3.0: + version "3.10.1" + resolved "https://registry.yarnpkg.com/xterm/-/xterm-3.10.1.tgz#14accf92772e5a6728f317a3c209ba714b73c8b5" + xterm@~3.8.1: version "3.8.1" resolved "https://registry.yarnpkg.com/xterm/-/xterm-3.8.1.tgz#0beabaccdc23bd3ab2397c5129ed9b06b0abb167"