From e2a9819e28738cf84ce73e92e00786defd38d0e1 Mon Sep 17 00:00:00 2001 From: Adnan Abdulhussein Date: Fri, 18 Jan 2019 13:23:10 -0800 Subject: [PATCH] more robust way of loading resources for AppView sections (#918) * store received resources from sockets in redux * wrap ServiceItems in a container and fetch Service data from Redux store * use apiVersion from returned resource The k8s API server keeps the apiVersion in the object consistent, so we can safely use it to key for our cache * just pass name down to ServiceItem instead of full serviceRef * add TODO comments about moving WebSockets to redux * add ResourceRef class for referencing resources * switch to using ResourceRef class from interface * add getResourceURL method to ResourceRef - make use of ResourceRef.getResourceURL in ServiceItemContainer to get cache key - use ResourceRef.getResourceURL as the React key for the map of ServiceItem components * fix AppView tests * remove unnecessary comment * fix tests * update ServiceItem tests switch from LoadingSpinner to LoadingWrapper * switch to main class constructor for ResourceRef * add ServiceItem test for calling getService * add ServiceItemContainer test * rename namespace to releaseNamespace in parseResources * ResourceRef: throw error if default namespace not defined * fix tests --- .../src/components/AppView/AppView.test.tsx | 9 +- dashboard/src/components/AppView/AppView.tsx | 46 +++++- .../ServicesTable/ServiceItem.test.tsx | 139 +++++++++++++----- .../AppView/ServicesTable/ServiceItem.tsx | 65 ++++++-- .../ServicesTable/ServicesTable.test.tsx | 79 +--------- .../AppView/ServicesTable/ServicesTable.tsx | 26 ++-- .../__snapshots__/ServiceItem.test.tsx.snap | 32 +++- .../__snapshots__/ServicesTable.test.tsx.snap | 36 ----- .../LoadingSpinner/LoadingSpinner.scss | 8 + .../LoadingSpinner/LoadingSpinner.tsx | 2 +- .../AppViewContainer/AppViewContainer.tsx | 5 +- .../ServiceItemContainer.test.tsx | 43 ++++++ .../ServiceItemContainer.ts | 44 ++++++ .../containers/ServiceItemContainer/index.ts | 3 + dashboard/src/shared/ResourceRef.test.ts | 90 ++++++++++++ dashboard/src/shared/ResourceRef.ts | 48 ++++++ 16 files changed, 483 insertions(+), 192 deletions(-) delete mode 100644 dashboard/src/components/AppView/ServicesTable/__snapshots__/ServicesTable.test.tsx.snap create mode 100644 dashboard/src/containers/ServiceItemContainer/ServiceItemContainer.test.tsx create mode 100644 dashboard/src/containers/ServiceItemContainer/ServiceItemContainer.ts create mode 100644 dashboard/src/containers/ServiceItemContainer/index.ts create mode 100644 dashboard/src/shared/ResourceRef.test.ts create mode 100644 dashboard/src/shared/ResourceRef.ts diff --git a/dashboard/src/components/AppView/AppView.test.tsx b/dashboard/src/components/AppView/AppView.test.tsx index 3e43543f5e5..c1d36b445b0 100644 --- a/dashboard/src/components/AppView/AppView.test.tsx +++ b/dashboard/src/components/AppView/AppView.test.tsx @@ -4,6 +4,7 @@ import { safeDump as yamlSafeDump, YAMLException } from "js-yaml"; import * as React from "react"; import { hapi } from "../../shared/hapi/release"; +import ResourceRef from "../../shared/ResourceRef"; import itBehavesLike from "../../shared/specs"; import { ForbiddenError, IResource, NotFoundError } from "../../shared/types"; import DeploymentStatus from "../DeploymentStatus"; @@ -42,6 +43,7 @@ describe("AppViewComponent", () => { getApp: jest.fn(), namespace: "my-happy-place", releaseName: "mr-sunshine", + receiveResource: jest.fn(), }; const resources = { @@ -289,6 +291,8 @@ describe("AppViewComponent", () => { const service = { isFetching: false, item: { + apiVersion: "v1", + kind: "Service", metadata: { name: "foo", }, @@ -296,8 +300,9 @@ describe("AppViewComponent", () => { }, }; const services = [service]; + const serviceRefs = [new ResourceRef(service.item as IResource, "default")]; - wrapper.setState({ ingresses, services }); + wrapper.setState({ ingresses, services, serviceRefs }); const accessURLTable = wrapper.find(AccessURLTable); expect(accessURLTable).toExist(); @@ -305,7 +310,7 @@ describe("AppViewComponent", () => { const svcTable = wrapper.find(ServiceTable); expect(svcTable).toExist(); - expect(svcTable.prop("services")).toEqual([service]); + expect(svcTable.prop("serviceRefs")).toEqual(serviceRefs); }); it("forwards other resources", () => { diff --git a/dashboard/src/components/AppView/AppView.tsx b/dashboard/src/components/AppView/AppView.tsx index 0c0c71be550..ed8af7d21c1 100644 --- a/dashboard/src/components/AppView/AppView.tsx +++ b/dashboard/src/components/AppView/AppView.tsx @@ -5,6 +5,8 @@ import * as React from "react"; import SecretTable from "../../containers/SecretsTableContainer"; import { Auth } from "../../shared/Auth"; import { hapi } from "../../shared/hapi/release"; +import { Kube } from "../../shared/Kube"; +import ResourceRef from "../../shared/ResourceRef"; import { IK8sList, IKubeItem, IRBACRole, IResource } from "../../shared/types"; import WebSocketHelper from "../../shared/WebSocketHelper"; import DeploymentStatus from "../DeploymentStatus"; @@ -28,11 +30,14 @@ export interface IAppViewProps { deleteError: Error | undefined; getApp: (releaseName: string, namespace: string) => void; deleteApp: (releaseName: string, namespace: string, purge: boolean) => Promise; + // TODO: remove once WebSockets are moved to Redux store (#882) + receiveResource: (p: { key: string; resource: IResource }) => void; } interface IAppViewState { deployments: Array>; services: Array>; + serviceRefs: ResourceRef[]; ingresses: Array>; // Other resources are not IKubeItems because // we are not fetching any information for them. @@ -45,6 +50,7 @@ interface IAppViewState { interface IPartialAppViewState { deployments: Array>; services: Array>; + serviceRefs: ResourceRef[]; ingresses: Array>; otherResources: IResource[]; secretNames: string[]; @@ -73,6 +79,7 @@ class AppView extends React.Component { ingresses: [], otherResources: [], services: [], + serviceRefs: [], secretNames: [], sockets: [], }; @@ -129,23 +136,39 @@ class AppView extends React.Component { const dropByName = (array: Array>) => { return _.dropWhile(array, r => r.item && r.item.metadata.name === resource.metadata.name); }; + let apiResource: string; switch (resource.kind) { case "Deployment": const newDeps = dropByName(this.state.deployments); newDeps.push(newItem); this.setState({ deployments: newDeps }); + apiResource = "deployments"; break; case "Service": const newSvcs = dropByName(this.state.services); newSvcs.push(newItem); this.setState({ services: newSvcs }); + apiResource = "services"; break; case "Ingress": const newIngresses = dropByName(this.state.ingresses); newIngresses.push(newItem); this.setState({ ingresses: newIngresses }); + apiResource = "ingresses"; break; + default: + // Unknown resource, ignore + return; } + // Construct the key used for the store + const resourceKey = Kube.getResourceURL( + resource.apiVersion, + apiResource, + resource.metadata.namespace, + resource.metadata.name, + ); + // TODO: this is temporary before we move WebSockets to the Redux store (#882) + this.props.receiveResource({ key: resourceKey, resource }); } public render() { @@ -166,7 +189,14 @@ class AppView extends React.Component { public appInfo() { const { app } = this.props; - const { services, ingresses, deployments, secretNames, otherResources } = this.state; + const { + services, + serviceRefs, + ingresses, + deployments, + secretNames, + otherResources, + } = this.state; return (
@@ -197,7 +227,7 @@ class AppView extends React.Component { - + @@ -209,13 +239,14 @@ class AppView extends React.Component { private parseResources( resources: Array>, - namespace: string, + releaseNamespace: string, ): IPartialAppViewState { const result: IPartialAppViewState = { deployments: [], ingresses: [], otherResources: [], services: [], + serviceRefs: [], secretNames: [], sockets: [], }; @@ -226,19 +257,20 @@ class AppView extends React.Component { case "Deployment": result.deployments.push(resource); result.sockets.push( - this.getSocket("deployments", i.apiVersion, item.metadata.name, namespace), + this.getSocket("deployments", i.apiVersion, item.metadata.name, releaseNamespace), ); break; case "Service": result.services.push(resource); + result.serviceRefs.push(new ResourceRef(resource.item, releaseNamespace)); result.sockets.push( - this.getSocket("services", i.apiVersion, item.metadata.name, namespace), + this.getSocket("services", i.apiVersion, item.metadata.name, releaseNamespace), ); break; case "Ingress": result.ingresses.push(resource); result.sockets.push( - this.getSocket("ingresses", i.apiVersion, item.metadata.name, namespace), + this.getSocket("ingresses", i.apiVersion, item.metadata.name, releaseNamespace), ); break; case "Secret": @@ -250,7 +282,7 @@ class AppView extends React.Component { // the List, concatenating items from both. _.assignWith( result, - this.parseResources((i as IK8sList).items, namespace), + this.parseResources((i as IK8sList).items, releaseNamespace), // Merge the list with the current result (prev, newArray) => prev.concat(newArray), ); diff --git a/dashboard/src/components/AppView/ServicesTable/ServiceItem.test.tsx b/dashboard/src/components/AppView/ServicesTable/ServiceItem.test.tsx index 32360ab9cb2..4f7164fae06 100644 --- a/dashboard/src/components/AppView/ServicesTable/ServiceItem.test.tsx +++ b/dashboard/src/components/AppView/ServicesTable/ServiceItem.test.tsx @@ -1,48 +1,115 @@ import { shallow } from "enzyme"; +import context from "jest-plugin-context"; import * as React from "react"; -import { IResource } from "shared/types"; +import itBehavesLike from "../../../shared/specs"; +import { IKubeItem, IResource } from "../../../shared/types"; import ServiceItem from "./ServiceItem"; -it("renders a simple view without IP", () => { - const service = { - metadata: { - name: "foo", - }, - spec: { - type: "ClusterIP", - ports: [], - }, - } as IResource; - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); +const kubeItem: IKubeItem = { + isFetching: false, +}; + +describe("componentDidMount", () => { + it("calls getService", () => { + const mock = jest.fn(); + shallow(); + expect(mock).toHaveBeenCalled(); + }); }); -it("renders a simple view with IP", () => { - const service = { - metadata: { - name: "foo", - }, - spec: { - ports: [{ port: 80 }], - type: "LoadBalancer", - }, - status: { - loadBalancer: { - ingress: [{ ip: "1.2.3.4" }], +context("when fetching services", () => { + [undefined, { isFetching: true }].forEach(service => { + itBehavesLike("aLoadingComponent", { + component: ServiceItem, + props: { + service, + getService: jest.fn(), }, - }, - } as IResource; - const wrapper = shallow(); - expect(wrapper.text()).toContain("1.2.3.4"); + }); + it("displays the name of the Service", () => { + const wrapper = shallow(); + expect(wrapper.text()).toContain("foo"); + }); + }); }); -it("renders a view with IP", () => { +context("when there is an error fetching the Service", () => { const service = { - metadata: { name: "foo" }, - spec: { ports: [{ port: 80 }], type: "LoadBalancer" }, - status: { loadBalancer: { ingress: [{ ip: "1.2.3.4" }] } }, - } as IResource; - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + error: new Error('services "foo" not found'), + isFetching: false, + }; + const wrapper = shallow(); + + it("diplays the Service name in the first column", () => { + expect( + wrapper + .find("td") + .first() + .text(), + ).toEqual("foo"); + }); + + it("displays the error message in the second column", () => { + expect( + wrapper + .find("td") + .at(1) + .text(), + ).toContain('Error: services "foo" not found'); + }); +}); + +context("when there is a valid Service", () => { + it("renders a simple view without IP", () => { + const service = { + metadata: { + name: "foo", + }, + spec: { + type: "ClusterIP", + ports: [], + }, + } as IResource; + kubeItem.item = service; + const wrapper = shallow( + , + ); + expect(wrapper).toMatchSnapshot(); + }); + + it("renders a simple view with IP", () => { + const service = { + metadata: { + name: "foo", + }, + spec: { + ports: [{ port: 80 }], + type: "LoadBalancer", + }, + status: { + loadBalancer: { + ingress: [{ ip: "1.2.3.4" }], + }, + }, + } as IResource; + kubeItem.item = service; + const wrapper = shallow( + , + ); + expect(wrapper.text()).toContain("1.2.3.4"); + }); + + it("renders a view with IP", () => { + const service = { + metadata: { name: "foo" }, + spec: { ports: [{ port: 80 }], type: "LoadBalancer" }, + status: { loadBalancer: { ingress: [{ ip: "1.2.3.4" }] } }, + } as IResource; + kubeItem.item = service; + const wrapper = shallow( + , + ); + expect(wrapper).toMatchSnapshot(); + }); }); diff --git a/dashboard/src/components/AppView/ServicesTable/ServiceItem.tsx b/dashboard/src/components/AppView/ServicesTable/ServiceItem.tsx index 09310a92b1d..d208fcd2ffa 100644 --- a/dashboard/src/components/AppView/ServicesTable/ServiceItem.tsx +++ b/dashboard/src/components/AppView/ServicesTable/ServiceItem.tsx @@ -1,32 +1,67 @@ import * as React from "react"; +import { AlertTriangle } from "react-feather"; -import { IResource, IServiceSpec, IServiceStatus } from "../../../shared/types"; +import LoadingWrapper from "../../../components/LoadingWrapper"; +import { IKubeItem, IResource, IServiceSpec, IServiceStatus } from "../../../shared/types"; interface IServiceItemProps { - service: IResource; + name: string; + service?: IKubeItem; + getService: () => void; } class ServiceItem extends React.Component { + public componentDidMount() { + this.props.getService(); + } + public render() { - const { service } = this.props; - const spec: IServiceSpec = service.spec; + const { name, service } = this.props; return ( - {service.metadata.name} - {spec.type} - {spec.clusterIP} - {this.getExternalIP()} - - {spec.ports - .map(p => `${p.port}${p.nodePort ? `:${p.nodePort}` : ""}/${p.protocol || "TCP"}`) - .join(", ")} - + {name} + {this.renderServiceInfo(service)} ); } - private getExternalIP(): string { - const { service } = this.props; + private renderServiceInfo(service?: IKubeItem) { + if (service === undefined || service.isFetching) { + return ( + + + + ); + } + if (service.error) { + return ( + + + + Error: {service.error.message} + + + ); + } + if (service.item) { + const spec: IServiceSpec = service.item.spec; + return ( + + {spec.type} + {spec.clusterIP} + {this.getExternalIP(service.item)} + + {spec.ports + .map(p => `${p.port}${p.nodePort ? `:${p.nodePort}` : ""}/${p.protocol || "TCP"}`) + .join(", ")} + + + ); + } + return null; + } + + private getExternalIP(service: IResource): string { const spec: IServiceSpec = service.spec; const status: IServiceStatus = service.status; if (spec.type !== "LoadBalancer") { diff --git a/dashboard/src/components/AppView/ServicesTable/ServicesTable.test.tsx b/dashboard/src/components/AppView/ServicesTable/ServicesTable.test.tsx index ced02d0f34d..2e9f12fa170 100644 --- a/dashboard/src/components/AppView/ServicesTable/ServicesTable.test.tsx +++ b/dashboard/src/components/AppView/ServicesTable/ServicesTable.test.tsx @@ -1,84 +1,11 @@ import { shallow } from "enzyme"; -import context from "jest-plugin-context"; import * as React from "react"; -import itBehavesLike from "../../../shared/specs"; - -import LoadingWrapper from "../../../components/LoadingWrapper"; -import { IResource } from "../../../shared/types"; import ServiceItem from "./ServiceItem"; import ServiceTable from "./ServicesTable"; -context("when fetching ingresses or services", () => { - itBehavesLike("aLoadingComponent", { - component: ServiceTable, - props: { - services: [{ isFetching: true }], - }, - }); -}); - it("renders a message if there are no services or ingresses", () => { - const wrapper = shallow(); - expect( - wrapper - .find(LoadingWrapper) - .shallow() - .find(ServiceItem), - ).not.toExist(); - expect( - wrapper - .find(LoadingWrapper) - .shallow() - .text(), - ).toContain("The current application does not contain any service"); -}); - -it("renders a table with a service with a LoadBalancer", () => { - const service = { - metadata: { - name: "foo", - }, - spec: { - type: "LoadBalancer", - }, - } as IResource; - const services = [{ isFetching: false, item: service }]; - const wrapper = shallow(); - expect(wrapper.find(ServiceItem).props()).toMatchObject({ - service: { metadata: { name: "foo" }, spec: { type: "LoadBalancer" } }, - }); -}); - -it("renders a table with a service with two services", () => { - const service1 = { - metadata: { - name: "foo", - }, - spec: { - type: "LoadBalancer", - }, - } as IResource; - const service2 = { - metadata: { - name: "bar", - }, - spec: { - type: "ClusterIP", - }, - } as IResource; - const services = [{ isFetching: false, item: service1 }, { isFetching: false, item: service2 }]; - const wrapper = shallow(); - expect( - wrapper - .find(ServiceItem) - .at(0) - .props(), - ).toMatchObject({ service: service1 }); - expect( - wrapper - .find(ServiceItem) - .at(1) - .props(), - ).toMatchObject({ service: service2 }); + const wrapper = shallow(); + expect(wrapper.find(ServiceItem)).not.toExist(); + expect(wrapper.text()).toContain("The current application does not contain any Service objects"); }); diff --git a/dashboard/src/components/AppView/ServicesTable/ServicesTable.tsx b/dashboard/src/components/AppView/ServicesTable/ServicesTable.tsx index 5cf6e2fc045..e7a01c1bca5 100644 --- a/dashboard/src/components/AppView/ServicesTable/ServicesTable.tsx +++ b/dashboard/src/components/AppView/ServicesTable/ServicesTable.tsx @@ -1,31 +1,26 @@ import * as React from "react"; -import LoadingWrapper from "../../../components/LoadingWrapper"; -import { IKubeItem, IResource } from "../../../shared/types"; -import isSomeResourceLoading from "../helpers"; -import ServiceItem from "./ServiceItem"; +import ServiceItem from "../../../containers/ServiceItemContainer"; +import ResourceRef from "../../../shared/ResourceRef"; interface IServiceTableProps { - services: Array>; + serviceRefs: ResourceRef[]; } class ServiceTable extends React.Component { public render() { - const { services } = this.props; return (
Services
- - {this.serviceSection()} - + {this.serviceSection()}
); } private serviceSection() { - const { services } = this.props; - let serviceSection =

The current application does not contain any service.

; - if (services.length > 0) { + const { serviceRefs } = this.props; + let serviceSection =

The current application does not contain any Service objects.

; + if (serviceRefs.length > 0) { serviceSection = ( @@ -38,10 +33,9 @@ class ServiceTable extends React.Component { - {services.map( - s => - s.item && , - )} + {serviceRefs.map(s => ( + + ))}
); diff --git a/dashboard/src/components/AppView/ServicesTable/__snapshots__/ServiceItem.test.tsx.snap b/dashboard/src/components/AppView/ServicesTable/__snapshots__/ServiceItem.test.tsx.snap index d3d3deb0f28..c13f91aeb69 100644 --- a/dashboard/src/components/AppView/ServicesTable/__snapshots__/ServiceItem.test.tsx.snap +++ b/dashboard/src/components/AppView/ServicesTable/__snapshots__/ServiceItem.test.tsx.snap @@ -1,6 +1,34 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`renders a simple view without IP 1`] = ` +exports[`when fetching services loading spinner matches the snapshot 1`] = ` + + + + + + +`; + +exports[`when fetching services loading spinner matches the snapshot 2`] = ` + + + + + + +`; + +exports[`when there is a valid Service renders a simple view without IP 1`] = ` foo @@ -16,7 +44,7 @@ exports[`renders a simple view without IP 1`] = ` `; -exports[`renders a view with IP 1`] = ` +exports[`when there is a valid Service renders a view with IP 1`] = ` foo diff --git a/dashboard/src/components/AppView/ServicesTable/__snapshots__/ServicesTable.test.tsx.snap b/dashboard/src/components/AppView/ServicesTable/__snapshots__/ServicesTable.test.tsx.snap deleted file mode 100644 index 5e641542c18..00000000000 --- a/dashboard/src/components/AppView/ServicesTable/__snapshots__/ServicesTable.test.tsx.snap +++ /dev/null @@ -1,36 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`when fetching ingresses or services loading spinner matches the snapshot 1`] = ` - -
- Services -
- - - - - - - - - - - - -
- NAME - - TYPE - - CLUSTER-IP - - EXTERNAL-IP - - PORT(S) -
-
-
-`; diff --git a/dashboard/src/components/LoadingSpinner/LoadingSpinner.scss b/dashboard/src/components/LoadingSpinner/LoadingSpinner.scss index fd1ba045f78..66a66fed5d4 100644 --- a/dashboard/src/components/LoadingSpinner/LoadingSpinner.scss +++ b/dashboard/src/components/LoadingSpinner/LoadingSpinner.scss @@ -22,6 +22,14 @@ $spinner-color: #00437b; &__bounce2 { animation-delay: -1s; } + + // Different styles/sizes + &--inline-small { + margin: 0 auto; + height: 15px; + width: 15px; + top: 2px; + } } @keyframes spinner-bounce { diff --git a/dashboard/src/components/LoadingSpinner/LoadingSpinner.tsx b/dashboard/src/components/LoadingSpinner/LoadingSpinner.tsx index 441830ce009..6748880361b 100644 --- a/dashboard/src/components/LoadingSpinner/LoadingSpinner.tsx +++ b/dashboard/src/components/LoadingSpinner/LoadingSpinner.tsx @@ -9,7 +9,7 @@ export interface ISpinnerProps { const LoaderSpinner: React.SFC = props => { // Based on http://tobiasahlin.com/spinkit/ return ( -
+
diff --git a/dashboard/src/containers/AppViewContainer/AppViewContainer.tsx b/dashboard/src/containers/AppViewContainer/AppViewContainer.tsx index dceb384adc0..0e7500921d8 100644 --- a/dashboard/src/containers/AppViewContainer/AppViewContainer.tsx +++ b/dashboard/src/containers/AppViewContainer/AppViewContainer.tsx @@ -4,7 +4,7 @@ import { ThunkDispatch } from "redux-thunk"; import actions from "../../actions"; import AppView from "../../components/AppView"; -import { IStoreState } from "../../shared/types"; +import { IResource, IStoreState } from "../../shared/types"; interface IRouteProps { match: { @@ -31,6 +31,9 @@ function mapDispatchToProps(dispatch: ThunkDispatch) deleteApp: (releaseName: string, ns: string, purge: boolean) => dispatch(actions.apps.deleteApp(releaseName, ns, purge)), getApp: (releaseName: string, ns: string) => dispatch(actions.apps.getApp(releaseName, ns)), + // TODO: remove once WebSockets are moved to Redux store (#882) + receiveResource: (payload: { key: string; resource: IResource }) => + dispatch(actions.kube.receiveResource(payload)), }; } diff --git a/dashboard/src/containers/ServiceItemContainer/ServiceItemContainer.test.tsx b/dashboard/src/containers/ServiceItemContainer/ServiceItemContainer.test.tsx new file mode 100644 index 00000000000..282a851f085 --- /dev/null +++ b/dashboard/src/containers/ServiceItemContainer/ServiceItemContainer.test.tsx @@ -0,0 +1,43 @@ +import { shallow } from "enzyme"; +import * as React from "react"; +import configureMockStore from "redux-mock-store"; +import thunk from "redux-thunk"; + +import { IKubeItem, IKubeState, IResource } from "shared/types"; +import ServiceItemContainer from "."; +import ServiceItem from "../../components/AppView/ServicesTable/ServiceItem"; +import ResourceRef from "../../shared/ResourceRef"; + +const mockStore = configureMockStore([thunk]); + +const makeStore = (secrets: { [s: string]: IKubeItem }) => { + const state: IKubeState = { + items: secrets, + }; + return mockStore({ kube: state }); +}; + +describe("ServiceItemContainer", () => { + it("maps Service in store to ServiceItem props", () => { + const ns = "wee"; + const name = "foo"; + const item = { isFetching: false, item: { metadata: { name } } as IResource }; + const store = makeStore({ + "api/kube/api/v1/namespaces/wee/services/foo": item, + }); + const ref = new ResourceRef({ + apiVersion: "v1", + kind: "Service", + metadata: { + namespace: ns, + name, + }, + } as IResource); + const wrapper = shallow(); + const form = wrapper.find(ServiceItem); + expect(form).toHaveProp({ + name, + service: item, + }); + }); +}); diff --git a/dashboard/src/containers/ServiceItemContainer/ServiceItemContainer.ts b/dashboard/src/containers/ServiceItemContainer/ServiceItemContainer.ts new file mode 100644 index 00000000000..6cec3eda229 --- /dev/null +++ b/dashboard/src/containers/ServiceItemContainer/ServiceItemContainer.ts @@ -0,0 +1,44 @@ +import * as _ from "lodash"; +import { connect } from "react-redux"; +import { Action } from "redux"; +import { ThunkDispatch } from "redux-thunk"; + +import actions from "../../actions"; +import ServiceItem from "../../components/AppView/ServicesTable/ServiceItem"; +import ResourceRef from "../../shared/ResourceRef"; +import { IStoreState } from "../../shared/types"; + +interface IServiceItemContainerProps { + serviceRef: ResourceRef; +} + +function mapStateToProps({ kube }: IStoreState, props: IServiceItemContainerProps) { + const { serviceRef } = props; + return { + name: serviceRef.name, + service: kube.items[serviceRef.getResourceURL()], + }; +} + +function mapDispatchToProps( + dispatch: ThunkDispatch, + props: IServiceItemContainerProps, +) { + const { serviceRef } = props; + return { + getService: () => + dispatch( + actions.kube.getResource( + serviceRef.apiVersion, + "services", + serviceRef.namespace, + serviceRef.name, + ), + ), + }; +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(ServiceItem); diff --git a/dashboard/src/containers/ServiceItemContainer/index.ts b/dashboard/src/containers/ServiceItemContainer/index.ts new file mode 100644 index 00000000000..b91cd8452c6 --- /dev/null +++ b/dashboard/src/containers/ServiceItemContainer/index.ts @@ -0,0 +1,3 @@ +import ServiceItemContainer from "./ServiceItemContainer"; + +export default ServiceItemContainer; diff --git a/dashboard/src/shared/ResourceRef.test.ts b/dashboard/src/shared/ResourceRef.test.ts new file mode 100644 index 00000000000..9726f33256f --- /dev/null +++ b/dashboard/src/shared/ResourceRef.test.ts @@ -0,0 +1,90 @@ +import { Kube } from "./Kube"; +import ResourceRef from "./ResourceRef"; +import { IResource } from "./types"; + +describe("ResourceRef", () => { + describe("constructor", () => { + it("it returns a ResourceRef with the correct details", () => { + const r = { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { + name: "foo", + namespace: "bar", + }, + } as IResource; + + const ref = new ResourceRef(r); + expect(ref).toBeInstanceOf(ResourceRef); + expect(ref).toEqual({ + apiVersion: r.apiVersion, + kind: r.kind, + name: r.metadata.name, + namespace: r.metadata.namespace, + }); + }); + + it("sets a default namespace if not in the resource", () => { + const r = { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { + name: "foo", + }, + } as IResource; + + const ref = new ResourceRef(r, "default"); + expect(ref.namespace).toBe("default"); + }); + + it("throws an error if namespace not in the resource or default namespace not set", () => { + const r = { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { + name: "foo", + }, + } as IResource; + + expect(() => new ResourceRef(r)).toThrowError(); + }); + + it("allows the default namespace to be provided", () => { + const r = { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { + name: "foo", + }, + } as IResource; + + const ref = new ResourceRef(r, "bar"); + expect(ref.namespace).toBe("bar"); + }); + }); + + describe("getResourceURL", () => { + let kubeGetResourceURLMock: jest.Mock; + beforeEach(() => { + kubeGetResourceURLMock = Kube.getResourceURL = jest.fn(); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it("calls Kube.getResourceURL with the correct arguments", () => { + const r = { + apiVersion: "v1", + kind: "Service", + metadata: { + name: "foo", + namespace: "bar", + }, + } as IResource; + + const ref = new ResourceRef(r); + + ref.getResourceURL(); + expect(kubeGetResourceURLMock).toBeCalledWith("v1", "services", "bar", "foo"); + }); + }); +}); diff --git a/dashboard/src/shared/ResourceRef.ts b/dashboard/src/shared/ResourceRef.ts new file mode 100644 index 00000000000..5d71a2d1833 --- /dev/null +++ b/dashboard/src/shared/ResourceRef.ts @@ -0,0 +1,48 @@ +import { Kube } from "./Kube"; +import { IResource } from "./types"; + +// ResourceRef defines a reference to a namespaced Kubernetes API Object and +// provides helpers to retrieve the resource URL +class ResourceRef { + public apiVersion: string; + public kind: string; + public name: string; + public namespace: string; + + // Creates a new ResourceRef instance from an existing IResource. Provide + // defaultNamespace to set if the IResource doesn't specify a namespace. + // TODO: add support for cluster-scoped resources, or add a ClusterResourceRef + // class. + constructor(r: IResource, defaultNamespace?: string) { + this.apiVersion = r.apiVersion; + this.kind = r.kind; + this.name = r.metadata.name; + const namespace = r.metadata.namespace || defaultNamespace; + if (!namespace) { + throw new Error(`Namespace missing for resource ${this.name}, define a default namespace`); + } + this.namespace = namespace; + return this; + } + + // Gets a full resource URL for the referenced resource + public getResourceURL() { + return Kube.getResourceURL(this.apiVersion, this.resourcePath(), this.namespace, this.name); + } + + // Gets the plural form of the resource Kind for use in the resource path + private resourcePath() { + // We explicitly define the plurals here, just in case a generic pluralizer + // isn't sufficient. Note that CRDs can explicitly define pluralized forms, + // which might not match with the Kind. If this becomes difficult to + // maintain we can add a generic pluralizer and a way to override. + switch (this.kind) { + case "Service": + return "services"; + default: + throw new Error(`Don't know path for ${this.kind}, register it in ResourceRef`); + } + } +} + +export default ResourceRef;