Skip to content

Commit

Permalink
more robust way of loading resources for AppView sections (#918)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
prydonius committed Jan 18, 2019
1 parent f95b5c2 commit e2a9819
Show file tree
Hide file tree
Showing 16 changed files with 483 additions and 192 deletions.
9 changes: 7 additions & 2 deletions dashboard/src/components/AppView/AppView.test.tsx
Expand Up @@ -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";
Expand Down Expand Up @@ -42,6 +43,7 @@ describe("AppViewComponent", () => {
getApp: jest.fn(),
namespace: "my-happy-place",
releaseName: "mr-sunshine",
receiveResource: jest.fn(),
};

const resources = {
Expand Down Expand Up @@ -289,23 +291,26 @@ describe("AppViewComponent", () => {
const service = {
isFetching: false,
item: {
apiVersion: "v1",
kind: "Service",
metadata: {
name: "foo",
},
spec: {},
},
};
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();
expect(accessURLTable.props()).toMatchObject({ ingresses: [ingress], services: [service] });

const svcTable = wrapper.find(ServiceTable);
expect(svcTable).toExist();
expect(svcTable.prop("services")).toEqual([service]);
expect(svcTable.prop("serviceRefs")).toEqual(serviceRefs);
});

it("forwards other resources", () => {
Expand Down
46 changes: 39 additions & 7 deletions dashboard/src/components/AppView/AppView.tsx
Expand Up @@ -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";
Expand All @@ -28,11 +30,14 @@ export interface IAppViewProps {
deleteError: Error | undefined;
getApp: (releaseName: string, namespace: string) => void;
deleteApp: (releaseName: string, namespace: string, purge: boolean) => Promise<boolean>;
// TODO: remove once WebSockets are moved to Redux store (#882)
receiveResource: (p: { key: string; resource: IResource }) => void;
}

interface IAppViewState {
deployments: Array<IKubeItem<IResource>>;
services: Array<IKubeItem<IResource>>;
serviceRefs: ResourceRef[];
ingresses: Array<IKubeItem<IResource>>;
// Other resources are not IKubeItems because
// we are not fetching any information for them.
Expand All @@ -45,6 +50,7 @@ interface IAppViewState {
interface IPartialAppViewState {
deployments: Array<IKubeItem<IResource>>;
services: Array<IKubeItem<IResource>>;
serviceRefs: ResourceRef[];
ingresses: Array<IKubeItem<IResource>>;
otherResources: IResource[];
secretNames: string[];
Expand Down Expand Up @@ -73,6 +79,7 @@ class AppView extends React.Component<IAppViewProps, IAppViewState> {
ingresses: [],
otherResources: [],
services: [],
serviceRefs: [],
secretNames: [],
sockets: [],
};
Expand Down Expand Up @@ -129,23 +136,39 @@ class AppView extends React.Component<IAppViewProps, IAppViewState> {
const dropByName = (array: Array<IKubeItem<IResource>>) => {
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() {
Expand All @@ -166,7 +189,14 @@ class AppView extends React.Component<IAppViewProps, IAppViewState> {

public appInfo() {
const { app } = this.props;
const { services, ingresses, deployments, secretNames, otherResources } = this.state;
const {
services,
serviceRefs,
ingresses,
deployments,
secretNames,
otherResources,
} = this.state;
return (
<section className="AppView padding-b-big">
<main>
Expand Down Expand Up @@ -197,7 +227,7 @@ class AppView extends React.Component<IAppViewProps, IAppViewState> {
<AppNotes notes={app.info && app.info.status && app.info.status.notes} />
<SecretTable namespace={app.namespace} secretNames={secretNames} />
<DeploymentsTable deployments={deployments} />
<ServicesTable services={services} />
<ServicesTable serviceRefs={serviceRefs} />
<OtherResourcesTable otherResources={otherResources} />
</div>
</div>
Expand All @@ -209,13 +239,14 @@ class AppView extends React.Component<IAppViewProps, IAppViewState> {

private parseResources(
resources: Array<IResource | IK8sList<IResource, {}>>,
namespace: string,
releaseNamespace: string,
): IPartialAppViewState {
const result: IPartialAppViewState = {
deployments: [],
ingresses: [],
otherResources: [],
services: [],
serviceRefs: [],
secretNames: [],
sockets: [],
};
Expand All @@ -226,19 +257,20 @@ class AppView extends React.Component<IAppViewProps, IAppViewState> {
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":
Expand All @@ -250,7 +282,7 @@ class AppView extends React.Component<IAppViewProps, IAppViewState> {
// the List, concatenating items from both.
_.assignWith(
result,
this.parseResources((i as IK8sList<IResource, {}>).items, namespace),
this.parseResources((i as IK8sList<IResource, {}>).items, releaseNamespace),
// Merge the list with the current result
(prev, newArray) => prev.concat(newArray),
);
Expand Down
139 changes: 103 additions & 36 deletions 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(<ServiceItem service={service} />);
expect(wrapper).toMatchSnapshot();
const kubeItem: IKubeItem<IResource> = {
isFetching: false,
};

describe("componentDidMount", () => {
it("calls getService", () => {
const mock = jest.fn();
shallow(<ServiceItem name="foo" getService={mock} />);
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(<ServiceItem service={service} />);
expect(wrapper.text()).toContain("1.2.3.4");
});
it("displays the name of the Service", () => {
const wrapper = shallow(<ServiceItem service={service} name="foo" getService={jest.fn()} />);
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(<ServiceItem service={service} />);
expect(wrapper).toMatchSnapshot();
error: new Error('services "foo" not found'),
isFetching: false,
};
const wrapper = shallow(<ServiceItem service={service} name="foo" getService={jest.fn()} />);

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(
<ServiceItem service={kubeItem} name={service.metadata.name} getService={jest.fn()} />,
);
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(
<ServiceItem service={kubeItem} name={service.metadata.name} getService={jest.fn()} />,
);
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(
<ServiceItem service={kubeItem} name={service.metadata.name} getService={jest.fn()} />,
);
expect(wrapper).toMatchSnapshot();
});
});

0 comments on commit e2a9819

Please sign in to comment.