Fix cluster frame display issue (#5518)

* Fix cluster frame display issue

- Add some defensive code to prevent this sort of infinite loop

- Add some unit tests

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* fix unit tests

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix build

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix lint

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Factor out injectable for getting of cluster config data

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton authored and GitHub committed 2022-06-20 22:23:15 -04:00
1 parent 8c31e56462
commit de43df1cca
40 files changed
+2126 -815

No files matched your search

@@ -39,9 +39,9 @@ interface Dependencies {
function getContexts(config: KubeConfig): Map<string, Option> {
return new Map(
splitConfig(config)
.map(({ config, error }) => [config.currentContext, {
.map(({ config, validationResult }) => [config.currentContext, {
config,
error,
error: validationResult.error?.toString(),
}]),
);
}
@@ -17,7 +17,6 @@ import { ClusterIssues } from "./cluster-issues";
import { ClusterMetrics } from "./cluster-metrics";
import type { ClusterOverviewStore } from "./cluster-overview-store/cluster-overview-store";
import { ClusterPieCharts } from "./cluster-pie-charts";
import { getActiveClusterEntity } from "../../api/catalog/entity/legacy-globals";
import { ClusterMetricsResourceType } from "../../../common/cluster-types";
import type { EventStore } from "../+events/store";
import { withInjectables } from "@ogre-tools/injectable-react";
@@ -71,7 +70,7 @@ class NonInjectedClusterOverview extends React.Component<Dependencies> {
this.metricPoller.stop();
}
renderMetrics(isMetricsHidden?: boolean) {
renderMetrics(isMetricsHidden: boolean) {
if (isMetricsHidden) {
return null;
}
@@ -84,7 +83,7 @@ class NonInjectedClusterOverview extends React.Component<Dependencies> {
);
}
renderClusterOverview(isLoaded: boolean, isMetricsHidden?: boolean) {
renderClusterOverview(isLoaded: boolean, isMetricsHidden: boolean) {
if (!isLoaded) {
return <Spinner center/>;
}
@@ -98,9 +97,9 @@ class NonInjectedClusterOverview extends React.Component<Dependencies> {
}
render() {
const { eventStore, nodeStore } = this.props;
const { eventStore, nodeStore, hostedCluster } = this.props;
const isLoaded = nodeStore.isLoaded && eventStore.isLoaded;
const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.Cluster);
const isMetricHidden = hostedCluster.isMetricHidden(ClusterMetricsResourceType.Cluster);
return (
<TabLayout>
@@ -12,51 +12,48 @@ import { CommandDialog } from "./command-dialog";
import type { ClusterId } from "../../../common/cluster-types";
import type { CommandOverlay } from "./command-overlay.injectable";
import commandOverlayInjectable from "./command-overlay.injectable";
import { isMac } from "../../../common/vars";
import type { CatalogEntityRegistry } from "../../api/catalog/entity/registry";
import { broadcastMessage, ipcRendererOn } from "../../../common/ipc";
import type { Disposer } from "../../utils";
import type { ipcRendererOn } from "../../../common/ipc";
import { broadcastMessage } from "../../../common/ipc";
import { withInjectables } from "@ogre-tools/injectable-react";
import type { AddWindowEventListener } from "../../window/event-listener.injectable";
import windowAddEventListenerInjectable from "../../window/event-listener.injectable";
import type { IComputedValue } from "mobx";
import matchedClusterIdInjectable from "../../navigation/matched-cluster-id.injectable";
import catalogEntityRegistryInjectable from "../../api/catalog/entity/registry.injectable";
import hostedClusterIdInjectable from "../../cluster-frame-context/hosted-cluster-id.injectable";
import isMacInjectable from "../../../common/vars/is-mac.injectable";
import legacyOnChannelListenInjectable from "../../ipc/legacy-channel-listen.injectable";
interface Dependencies {
addWindowEventListener: <K extends keyof WindowEventMap>(type: K, listener: (this: Window, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions) => Disposer;
addWindowEventListener: AddWindowEventListener;
commandOverlay: CommandOverlay;
clusterId?: ClusterId;
matchedClusterId: IComputedValue<ClusterId>;
entityRegistry: CatalogEntityRegistry;
clusterId: ClusterId | undefined;
matchedClusterId: IComputedValue<ClusterId | undefined>;
isMac: boolean;
legacyOnChannelListen: typeof ipcRendererOn;
}
@observer
class NonInjectedCommandContainer extends React.Component<Dependencies> {
private escHandler(event: KeyboardEvent) {
const { commandOverlay } = this.props;
private escHandler = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.stopPropagation();
commandOverlay.close();
this.props.commandOverlay.close();
}
}
};
handleCommandPalette = () => {
const { commandOverlay, entityRegistry } = this.props;
const clusterIsActive = this.props.matchedClusterId.get() !== undefined;
const matchedClusterId = this.props.matchedClusterId.get();
if (clusterIsActive) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
broadcastMessage(`command-palette:${entityRegistry.activeEntity!.getId()}:open`);
if (matchedClusterId !== undefined) {
broadcastMessage(`command-palette:${matchedClusterId}:open`);
} else {
commandOverlay.open(<CommandDialog />);
this.props.commandOverlay.open(<CommandDialog />);
}
};
onKeyboardShortcut(action: () => void) {
return ({ key, shiftKey, ctrlKey, altKey, metaKey }: KeyboardEvent) => {
const ctrlOrCmd = isMac ? metaKey && !ctrlKey : !metaKey && ctrlKey;
const ctrlOrCmd = this.props.isMac ? metaKey && !ctrlKey : !metaKey && ctrlKey;
if (key === "p" && shiftKey && ctrlOrCmd && !altKey) {
action();
@@ -75,9 +72,9 @@ class NonInjectedCommandContainer extends React.Component<Dependencies> {
: "command-palette:open";
disposeOnUnmount(this, [
ipcRendererOn(ipcChannel, action),
this.props.legacyOnChannelListen(ipcChannel, action),
addWindowEventListener("keydown", this.onKeyboardShortcut(action)),
addWindowEventListener("keyup", (e) => this.escHandler(e), true),
addWindowEventListener("keyup", this.escHandler, true),
]);
}
@@ -106,6 +103,7 @@ export const CommandContainer = withInjectables<Dependencies>(NonInjectedCommand
addWindowEventListener: di.inject(windowAddEventListenerInjectable),
commandOverlay: di.inject(commandOverlayInjectable),
matchedClusterId: di.inject(matchedClusterIdInjectable),
entityRegistry: di.inject(catalogEntityRegistryInjectable),
isMac: di.inject(isMacInjectable),
legacyOnChannelListen: di.inject(legacyOnChannelListenInjectable),
}),
});
@@ -9,9 +9,8 @@ import type { RenderResult } from "@testing-library/react";
import mockFs from "mock-fs";
import React from "react";
import * as selectEvent from "react-select-event";
import type { Cluster } from "../../../../common/cluster/cluster";
import { DeleteClusterDialog } from "../view";
import type { ClusterModel } from "../../../../common/cluster-types";
import type { CreateCluster } from "../../../../common/cluster/create-cluster-injection-token";
import { createClusterInjectionToken } from "../../../../common/cluster/create-cluster-injection-token";
import createContextHandlerInjectable from "../../../../main/context-handler/create-context-handler.injectable";
import type { OpenDeleteClusterDialog } from "../open.injectable";
@@ -46,14 +45,16 @@ jest.mock("electron", () => ({
},
}));
const currentClusterServerUrl = "https://localhost";
const nonCurrentClusterServerUrl = "http://localhost";
const multiClusterConfig = `
apiVersion: v1
clusters:
- cluster:
server: https://localhost
server: ${currentClusterServerUrl}
name: some-current-context-cluster
- cluster:
server: http://localhost
server: ${nonCurrentClusterServerUrl}
name: some-non-current-context-cluster
contexts:
- context:
@@ -73,11 +74,12 @@ users:
token: kubeconfig-user-q4lm4:xxxyyyy
`;
const singleClusterServerUrl = "http://localhost";
const singleClusterConfig = `
apiVersion: v1
clusters:
- cluster:
server: http://localhost
server: ${singleClusterServerUrl}
name: some-cluster
contexts:
- context:
@@ -97,7 +99,7 @@ let config: KubeConfig;
describe("<DeleteClusterDialog />", () => {
let applicationBuilder: ApplicationBuilder;
let createCluster: (model: ClusterModel) => Cluster;
let createCluster: CreateCluster;
let openDeleteClusterDialog: OpenDeleteClusterDialog;
beforeEach(async () => {
@@ -157,6 +159,8 @@ describe("<DeleteClusterDialog />", () => {
clusterName: "some-current-context-cluster",
},
kubeConfigPath: "./temp-kube-config",
}, {
clusterServerUrl: currentClusterServerUrl,
});
openDeleteClusterDialog({ cluster, config });
@@ -204,6 +208,8 @@ describe("<DeleteClusterDialog />", () => {
clusterName: "minikube",
},
kubeConfigPath: "./temp-kube-config",
}, {
clusterServerUrl: nonCurrentClusterServerUrl,
});
openDeleteClusterDialog({ cluster, config });
@@ -221,6 +227,8 @@ describe("<DeleteClusterDialog />", () => {
clusterName: "some-current-context-cluster",
},
kubeConfigPath: "./temp-kube-config",
}, {
clusterServerUrl: currentClusterServerUrl,
});
openDeleteClusterDialog({ cluster, config });
@@ -236,6 +244,8 @@ describe("<DeleteClusterDialog />", () => {
clusterName: "some-current-context-cluster",
},
kubeConfigPath: "./temp-kube-config",
}, {
clusterServerUrl: currentClusterServerUrl,
});
openDeleteClusterDialog({ cluster, config });
@@ -259,12 +269,12 @@ describe("<DeleteClusterDialog />", () => {
const cluster = createCluster({
id: "some-non-current-context-cluster",
contextName: "some-non-current-context",
preferences: {
clusterName: "some-non-current-context-cluster",
},
kubeConfigPath: "./temp-kube-config",
}, {
clusterServerUrl: nonCurrentClusterServerUrl,
});
const spy = jest.spyOn(cluster, "isInLocalKubeconfig").mockImplementation(() => true);
@@ -301,6 +311,8 @@ describe("<DeleteClusterDialog />", () => {
clusterName: "some-cluster",
},
kubeConfigPath: "./temp-kube-config",
}, {
clusterServerUrl: singleClusterServerUrl,
});
openDeleteClusterDialog({ cluster, config });
@@ -80,9 +80,7 @@ class NonInjectedSidebarItem extends React.Component<
return (
<div
className={cssNames("SidebarItem")}
data-testid="sidebar-item"
data-test-id={this.id}
data-id-test={this.id}
data-testid={`sidebar-item-${this.id}`}
data-is-active-test={this.isActive}
data-parent-id-test={this.registration.parentId}
>
@@ -28,7 +28,7 @@ const createClusterInjectable = getInjectable({
createVersionDetector: () => { throw new Error("Tried to access back-end feature in front-end."); },
};
return (model) => new Cluster(dependencies, model);
return (model, configData) => new Cluster(dependencies, model, configData);
},
injectionToken: createClusterInjectionToken,
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,11 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.centering {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
@@ -0,0 +1,124 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { DiContainer } from "@ogre-tools/injectable";
import type { RenderResult } from "@testing-library/react";
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import { render as testingLibraryRender } from "@testing-library/react";
import React from "react";
import { DiContextProvider } from "@ogre-tools/injectable-react";
import { Router } from "react-router";
import { DefaultProps } from "../../mui-base-theme";
import { ClusterFrame } from "./cluster-frame";
import historyInjectable from "../../navigation/history.injectable";
import { computed } from "mobx";
import type { Cluster } from "../../../common/cluster/cluster";
import createClusterInjectable from "../../create-cluster/create-cluster.injectable";
import subscribeStoresInjectable from "../../kube-watch-api/subscribe-stores.injectable";
import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable";
import legacyOnChannelListenInjectable from "../../ipc/legacy-channel-listen.injectable";
import currentRouteComponentInjectable from "../../routes/current-route-component.injectable";
import allowedResourcesInjectable from "../../cluster-frame-context/allowed-resources.injectable";
import hostedClusterIdInjectable from "../../cluster-frame-context/hosted-cluster-id.injectable";
import hostedClusterInjectable from "../../cluster-frame-context/hosted-cluster.injectable";
describe("<ClusterFrame />", () => {
let render: () => RenderResult;
let di: DiContainer;
let cluster: Cluster;
beforeEach(() => {
di = getDiForUnitTesting({ doGeneralOverrides: true });
render = () => testingLibraryRender((
<DiContextProvider value={{ di }}>
<Router history={di.inject(historyInjectable)}>
{DefaultProps(ClusterFrame)}
</Router>
</DiContextProvider>
));
di.override(subscribeStoresInjectable, () => jest.fn().mockImplementation(() => jest.fn()));
di.override(legacyOnChannelListenInjectable, () => jest.fn().mockImplementation(() => jest.fn()));
di.override(directoryForUserDataInjectable, () => "/some/irrelavent/path");
di.override(storesAndApisCanBeCreatedInjectable, () => true);
const createCluster = di.inject(createClusterInjectable);
cluster = createCluster(
{
contextName: "my-cluster",
id: "123456",
kubeConfigPath: "/irrelavent",
},
{
clusterServerUrl: "https://localhost",
},
);
di.override(hostedClusterInjectable, () => cluster);
di.override(hostedClusterIdInjectable, () => cluster.id);
});
describe("given cluster with list nodes and namespaces permissions", () => {
beforeEach(() => {
di.override(allowedResourcesInjectable, () => computed(() => new Set(["nodes", "namespaces"])));
});
it("renders", () => {
const result = render();
expect(result.container).toMatchSnapshot();
});
it("shows cluster overview sidebar item as active", () => {
const result = render();
const clusterOverviewSidebarItem = result.getByTestId("sidebar-item-cluster-overview");
expect(clusterOverviewSidebarItem.getAttribute("data-is-active-test")).toBe("true");
});
describe("given no matching component", () => {
beforeEach(() => {
di.override(currentRouteComponentInjectable, () => computed(() => undefined));
});
describe("given current url is starting url", () => {
it("renders", () => {
const result = render();
expect(result.container).toMatchSnapshot();
});
it("shows warning message", () => {
const result = render();
expect(
result.getByText("An error has occured. No route can be found matching the current route, which is also the starting route."),
).toBeInTheDocument();
});
});
});
});
describe("given cluster without list nodes, but with namespaces permissions", () => {
beforeEach(() => {
di.override(allowedResourcesInjectable, () => computed(() => new Set(["namespaces"])));
});
it("renders", () => {
const result = render();
expect(result.container).toMatchSnapshot();
});
it("shows workloads overview sidebar item as active", () => {
const result = render();
const workloadsOverviewSidebarItem = result.getByTestId("sidebar-item-workloads");
expect(workloadsOverviewSidebarItem.getAttribute("data-is-active-test")).toBe("true");
});
});
});
@@ -2,6 +2,7 @@
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./cluster-frame.module.css";
import React, { useEffect } from "react";
import type { IComputedValue } from "mobx";
import { observer } from "mobx-react";
@@ -30,12 +31,14 @@ import { disposer } from "../../utils";
import currentRouteComponentInjectable from "../../routes/current-route-component.injectable";
import startUrlInjectable from "./start-url.injectable";
import subscribeStoresInjectable from "../../kube-watch-api/subscribe-stores.injectable";
import currentPathInjectable from "../../routes/current-path.injectable";
interface Dependencies {
namespaceStore: NamespaceStore;
currentRouteComponent: IComputedValue<React.ElementType<{}> | undefined | null>;
currentRouteComponent: IComputedValue<React.ElementType<{}> | undefined>;
startUrl: IComputedValue<string>;
subscribeStores: SubscribeStores;
currentPath: IComputedValue<string>;
}
export const NonInjectedClusterFrame = observer(({
@@ -43,6 +46,7 @@ export const NonInjectedClusterFrame = observer(({
currentRouteComponent,
startUrl,
subscribeStores,
currentPath,
}: Dependencies) => {
useEffect(() => disposer(
subscribeStores([
@@ -52,6 +56,8 @@ export const NonInjectedClusterFrame = observer(({
), []);
const Component = currentRouteComponent.get();
const starting = startUrl.get();
const current = currentPath.get();
return (
<ErrorBoundary>
@@ -62,7 +68,16 @@ export const NonInjectedClusterFrame = observer(({
{
Component
? <Component />
: <Redirect to={startUrl.get()} />
// NOTE: this check is to prevent an infinite loop
: starting !== current
? <Redirect to={startUrl.get()} />
: (
<div className={styles.centering}>
<div className="error">
An error has occured. No route can be found matching the current route, which is also the starting route.
</div>
</div>
)
}
</MainLayout>
@@ -87,6 +102,7 @@ export const ClusterFrame = withInjectables<Dependencies>(NonInjectedClusterFram
subscribeStores: di.inject(subscribeStoresInjectable),
startUrl: di.inject(startUrlInjectable),
currentRouteComponent: di.inject(currentRouteComponentInjectable),
currentPath: di.inject(currentPathInjectable),
}),
});
@@ -4,29 +4,30 @@
*/
import { getInjectable } from "@ogre-tools/injectable";
import { computed } from "mobx";
import type { KubeResource } from "../../../common/rbac";
import isAllowedResourceInjectable from "../../../common/utils/is-allowed-resource.injectable";
import clusterOverviewRouteInjectable from "../../../common/front-end-routing/routes/cluster/overview/cluster-overview-route.injectable";
import workloadsOverviewRouteInjectable from "../../../common/front-end-routing/routes/cluster/workloads/overview/workloads-overview-route.injectable";
import { buildURL } from "../../../common/utils/buildUrl";
const startUrlInjectable = getInjectable({
id: "start-url",
instantiate: (di) => {
const isAllowedResource = (resourceName: string) => di.inject(isAllowedResourceInjectable, resourceName);
const clusterOverviewRoute = di.inject(clusterOverviewRouteInjectable);
const workloadOverviewRoute = di.inject(workloadsOverviewRouteInjectable);
const clusterOverviewUrl = buildURL(clusterOverviewRoute.path);
const workloadOverviewUrl = buildURL(workloadOverviewRoute.path);
return computed(() => {
const resources: KubeResource[] = ["events", "nodes", "pods"];
if (clusterOverviewRoute.isEnabled.get()) {
return clusterOverviewRoute.path;
}
return resources.every((resourceName) => isAllowedResource(resourceName))
? clusterOverviewUrl
: workloadOverviewUrl;
if (workloadOverviewRoute.isEnabled.get()) {
return workloadOverviewRoute.path;
}
/**
* NOTE: This will never be executed as `workloadOverviewRoute.isEnabled` always is true. It
* is here is guard against accidental changes at a distance within `workloadOverviewRoute`.
*/
throw new Error("Exhausted all possible starting locations and none are active. This is a bug.");
});
},
});
@@ -0,0 +1,15 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { ipcRendererOn } from "../../common/ipc";
const legacyOnChannelListenInjectable = getInjectable({
id: "legacy-on-channel-listen",
instantiate: () => ipcRendererOn,
causesSideEffects: true,
});
export default legacyOnChannelListenInjectable;
@@ -6,6 +6,8 @@
import { getInjectable } from "@ogre-tools/injectable";
import type { Disposer } from "../utils";
export type AddWindowEventListener = typeof addWindowEventListener;
function addWindowEventListener<K extends keyof WindowEventMap>(type: K, listener: (this: Window, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions): Disposer {
window.addEventListener(type, listener, options);