1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

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>
This commit is contained in:
Sebastian Malton 2022-05-30 08:29:31 -04:00
parent 96ed99a06b
commit 5dca04a12d
22 changed files with 1822 additions and 104 deletions

View File

@ -7,14 +7,13 @@ import fs from "fs";
import mockFs from "mock-fs";
import path from "path";
import fse from "fs-extra";
import type { Cluster } from "../cluster/cluster";
import type { ClusterStore } from "../cluster-store/cluster-store";
import { Console } from "console";
import { stdout, stderr } from "process";
import getCustomKubeConfigDirectoryInjectable from "../app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable";
import clusterStoreInjectable from "../cluster-store/cluster-store.injectable";
import type { ClusterModel } from "../cluster-types";
import type { DiContainer } from "@ogre-tools/injectable";
import type { CreateCluster } from "../cluster/create-cluster-injection-token";
import { createClusterInjectionToken } from "../cluster/create-cluster-injection-token";
import directoryForUserDataInjectable from "../app-paths/directory-for-user-data/directory-for-user-data.injectable";
import { getDiForUnitTesting } from "../../main/getDiForUnitTesting";
@ -25,17 +24,19 @@ import directoryForTempInjectable from "../app-paths/directory-for-temp/director
import kubectlBinaryNameInjectable from "../../main/kubectl/binary-name.injectable";
import kubectlDownloadingNormalizedArchInjectable from "../../main/kubectl/normalized-arch.injectable";
import normalizedPlatformInjectable from "../vars/normalized-platform.injectable";
import fsInjectable from "../fs/fs.injectable";
console = new Console(stdout, stderr);
const testDataIcon = fs.readFileSync(
"test-data/cluster-store-migration-icon.png",
);
const clusterServerUrl = "https://localhost";
const kubeconfig = `
apiVersion: v1
clusters:
- cluster:
server: https://localhost
server: ${clusterServerUrl}
name: test
contexts:
- context:
@ -78,7 +79,7 @@ jest.mock("electron", () => ({
describe("cluster-store", () => {
let mainDi: DiContainer;
let clusterStore: ClusterStore;
let createCluster: (model: ClusterModel) => Cluster;
let createCluster: CreateCluster;
beforeEach(async () => {
mainDi = getDiForUnitTesting({ doGeneralOverrides: true });
@ -94,6 +95,7 @@ describe("cluster-store", () => {
mainDi.permitSideEffects(getConfigurationFileModelInjectable);
mainDi.permitSideEffects(appVersionInjectable);
mainDi.permitSideEffects(clusterStoreInjectable);
mainDi.permitSideEffects(fsInjectable);
mainDi.unoverride(clusterStoreInjectable);
});
@ -143,6 +145,8 @@ describe("cluster-store", () => {
getCustomKubeConfigDirectory("foo"),
kubeconfig,
),
}, {
clusterServerUrl,
});
clusterStore.addCluster(cluster);

View File

@ -16,13 +16,17 @@ import { disposer, toJS } from "../utils";
import type { ClusterModel, ClusterId, ClusterState } from "../cluster-types";
import { requestInitialClusterStates } from "../../renderer/ipc";
import { clusterStates } from "../ipc/cluster";
import type { CreateCluster } from "../cluster/create-cluster-injection-token";
import { loadConfigFromString, validateKubeConfig } from "../kube-helpers";
import type { ReadFileSync } from "../fs/read-file-sync.injectable";
export interface ClusterStoreModel {
clusters?: ClusterModel[];
}
interface Dependencies {
createCluster: (model: ClusterModel) => Cluster;
createCluster: CreateCluster;
readFileSync: ReadFileSync;
}
export class ClusterStore extends BaseStore<ClusterStoreModel> {
@ -111,12 +115,24 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
return undefined;
}
private createNewCluster(model: ClusterModel): Cluster {
const kubeConfigData = this.dependencies.readFileSync(model.kubeConfigPath);
const { config } = loadConfigFromString(kubeConfigData);
const result = validateKubeConfig(config, model.contextName);
if (result.error) {
throw result.error;
}
return this.dependencies.createCluster(model, { clusterServerUrl: result.cluster.server });
}
addCluster(clusterOrModel: ClusterModel | Cluster): Cluster {
appEventBus.emit({ name: "cluster", action: "add" });
const cluster = clusterOrModel instanceof Cluster
? clusterOrModel
: this.dependencies.createCluster(clusterOrModel);
: this.createNewCluster(clusterOrModel);
this.clusters.set(cluster.id, cluster);
@ -136,7 +152,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
if (cluster) {
cluster.updateModel(clusterModel);
} else {
cluster = this.dependencies.createCluster(clusterModel);
cluster = this.createNewCluster(clusterModel);
}
newClusters.set(clusterModel.id, cluster);
} catch (error) {

View File

@ -3,6 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
/**
* JSON serializable metadata type
*/
@ -67,6 +68,15 @@ export interface ClusterModel {
labels?: Record<string, string>;
}
/**
* This data is retreived from the kubeconfig file before calling the cluster constructor.
*
* That is done to remove the external dependency on the construction of Cluster instances.
*/
export interface ClusterConfigData {
clusterServerUrl: string;
}
/**
* The complete set of cluster settings or preferences
*/

View File

@ -10,13 +10,13 @@ import type { KubeConfig } from "@kubernetes/client-node";
import { HttpError } from "@kubernetes/client-node";
import type { Kubectl } from "../../main/kubectl/kubectl";
import type { KubeconfigManager } from "../../main/kubeconfig-manager/kubeconfig-manager";
import { loadConfigFromFile, loadConfigFromFileSync, validateKubeConfig } from "../kube-helpers";
import { loadConfigFromFile } from "../kube-helpers";
import type { KubeApiResource, KubeResource } from "../rbac";
import { apiResourceRecord, apiResources } from "../rbac";
import type { VersionDetector } from "../../main/cluster-detectors/version-detector";
import type { DetectorRegistry } from "../../main/cluster-detectors/detector-registry";
import plimit from "p-limit";
import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel, KubeAuthUpdate } from "../cluster-types";
import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel, KubeAuthUpdate, ClusterConfigData } from "../cluster-types";
import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus } from "../cluster-types";
import { disposer, isDefined, isRequestError, toJS } from "../utils";
import type { Response } from "request";
@ -236,27 +236,11 @@ export class Cluster implements ClusterModel, ClusterState {
return this.preferences.defaultNamespace;
}
constructor(private readonly dependencies: ClusterDependencies, model: ClusterModel) {
constructor(private readonly dependencies: ClusterDependencies, model: ClusterModel, configData: ClusterConfigData) {
makeObservable(this);
this.id = model.id;
this.updateModel(model);
const { config } = loadConfigFromFileSync(this.kubeConfigPath);
const validationError = validateKubeConfig(config, this.contextName);
if (validationError) {
throw validationError;
}
const context = config.getContextObject(this.contextName);
assert(context);
const cluster = config.getCluster(context.cluster);
assert(cluster);
this.apiUrl = cluster.server;
this.apiUrl = configData.clusterServerUrl;
// for the time being, until renderer gets its own cluster type
this._contextHandler = this.dependencies.createContextHandler(this);

View File

@ -3,10 +3,10 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectionToken } from "@ogre-tools/injectable";
import type { ClusterModel } from "../cluster-types";
import type { ClusterConfigData, ClusterModel } from "../cluster-types";
import type { Cluster } from "./cluster";
export type CreateCluster = (model: ClusterModel) => Cluster;
export type CreateCluster = (model: ClusterModel, configData: ClusterConfigData) => Cluster;
export const createClusterInjectionToken = getInjectionToken<CreateCluster>({
id: "create-cluster-token",

View File

@ -0,0 +1,19 @@
/**
* 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 fsInjectable from "./fs.injectable";
export type ReadFileSync = (filePath: string) => string;
const readFileSyncInjectable = getInjectable({
id: "read-file-sync",
instantiate: (di): ReadFileSync => {
const { readFileSync } = di.inject(fsInjectable);
return (filePath) => readFileSync(filePath, "utf-8");
},
});
export default readFileSyncInjectable;

View File

@ -150,7 +150,7 @@ export function loadConfigFromString(content: string): ConfigResult {
export interface SplitConfigEntry {
config: KubeConfig;
error?: string;
validationResult: ValidateKubeConfigResult;
}
/**
@ -179,7 +179,7 @@ export function splitConfig(kubeConfig: KubeConfig): SplitConfigEntry[] {
return {
config,
error: validateKubeConfig(config, ctx.name)?.toString(),
validationResult: validateKubeConfig(config, ctx.name),
};
});
}
@ -243,25 +243,44 @@ export function dumpConfigYaml(kubeConfig: PartialDeep<KubeConfig>): string {
return yaml.dump(config, { skipInvalid: true });
}
export type ValidateKubeConfigResult = {
error: Error;
} | {
error?: undefined;
context: Context;
cluster: Cluster;
user: User;
};
/**
* Checks if `config` has valid `Context`, `User`, `Cluster`, and `exec` fields (if present when required)
*
* Note: This function returns an error instead of throwing it, returning `undefined` if the validation passes
*/
export function validateKubeConfig(config: KubeConfig, contextName: string): Error | undefined {
const contextObject = config.getContextObject(contextName);
export function validateKubeConfig(config: KubeConfig, contextName: string): ValidateKubeConfigResult {
const context = config.getContextObject(contextName);
if (!contextObject) {
return new Error(`No valid context object provided in kubeconfig for context '${contextName}'`);
if (!context) {
return {
error: new Error(`No valid context object provided in kubeconfig for context '${contextName}'`),
};
}
if (!config.getCluster(contextObject.cluster)) {
return new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`);
const cluster = config.getCluster(context.cluster);
if (!cluster) {
return {
error: new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`),
};
}
if (!config.getUser(contextObject.user)) {
return new Error(`No valid user object provided in kubeconfig for context '${contextName}'`);
const user = config.getUser(context.user);
if (!user) {
return {
error: new Error(`No valid user object provided in kubeconfig for context '${contextName}'`),
};
}
return undefined;
return { cluster, user, context };
}

View File

@ -2,4 +2,5 @@
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "@testing-library/jest-dom/extend-expect";
import "@testing-library/jest-dom";

View File

@ -25,9 +25,11 @@ import { createHash } from "crypto";
import { homedir } from "os";
import globToRegExp from "glob-to-regexp";
import { inspect } from "util";
import type { ClusterModel, UpdateClusterModel } from "../../../common/cluster-types";
import type { ClusterConfigData, UpdateClusterModel } from "../../../common/cluster-types";
import type { Cluster } from "../../../common/cluster/cluster";
import type { CatalogEntityRegistry } from "../../catalog/entity-registry";
import type { CreateCluster } from "../../../common/cluster/create-cluster-injection-token";
import assert from "assert";
const logPrefix = "[KUBECONFIG-SYNC]:";
@ -56,7 +58,7 @@ interface KubeconfigSyncManagerDependencies {
readonly directoryForKubeConfigs: string;
readonly entityRegistry: CatalogEntityRegistry;
readonly clusterManager: ClusterManager;
createCluster: (model: ClusterModel) => Cluster;
createCluster: CreateCluster;
}
const kubeConfigSyncName = "lens:kube-sync";
@ -147,17 +149,26 @@ export class KubeconfigSyncManager {
}
// exported for testing
export function configToModels(rootConfig: KubeConfig, filePath: string): UpdateClusterModel[] {
const validConfigs = [];
export function configToModels(rootConfig: KubeConfig, filePath: string): [UpdateClusterModel, ClusterConfigData][] {
const validConfigs: ReturnType<typeof configToModels> = [];
for (const { config, error } of splitConfig(rootConfig)) {
if (error) {
logger.debug(`${logPrefix} context failed validation: ${error}`, { context: config.currentContext, filePath });
} else {
validConfigs.push({
kubeConfigPath: filePath,
contextName: config.currentContext,
});
const cluster = config.getCluster(config.currentContext);
assert(cluster, "Config somehow passed validations but still doesn't have a cluster");
validConfigs.push([
{
kubeConfigPath: filePath,
contextName: config.currentContext,
},
{
clusterServerUrl: cluster.server,
},
]);
}
}
@ -169,7 +180,7 @@ type RootSource = ObservableMap<string, RootSourceValue>;
interface ComputeDiffDependencies {
directoryForKubeConfigs: string;
createCluster: (model: ClusterModel) => Cluster;
createCluster: CreateCluster;
clusterManager: ClusterManager;
}
@ -184,15 +195,15 @@ export const computeDiff = ({ directoryForKubeConfigs, createCluster, clusterMan
}
const rawModels = configToModels(config, filePath);
const models = new Map(rawModels.map(m => [m.contextName, m]));
const models = new Map(rawModels.map(([model, configData]) => [model.contextName, [model, configData] as const]));
logger.debug(`${logPrefix} File now has ${models.size} entries`, { filePath });
for (const [contextName, value] of source) {
const model = models.get(contextName);
const data = models.get(contextName);
// remove and disconnect clusters that were removed from the config
if (!model) {
if (!data) {
// remove from the deleting set, so that if a new context of the same name is added, it isn't marked as deleting
clusterManager.deleting.delete(value[0].id);
@ -207,17 +218,17 @@ export const computeDiff = ({ directoryForKubeConfigs, createCluster, clusterMan
// diff against that
// or update the model and mark it as not needed to be added
value[0].updateModel(model);
value[0].updateModel(data[0]);
models.delete(contextName);
logger.debug(`${logPrefix} Updated old cluster from sync`, { filePath, contextName });
}
for (const [contextName, model] of models) {
for (const [contextName, [model, configData]] of models) {
// add new clusters to the source
try {
const clusterId = createHash("md5").update(`${filePath}:${contextName}`).digest("hex");
const cluster = ClusterStore.getInstance().getById(clusterId) || createCluster({ ...model, id: clusterId });
const cluster = ClusterStore.getInstance().getById(clusterId) || createCluster({ ...model, id: clusterId }, configData);
if (!cluster.apiUrl) {
throw new Error("Cluster constructor failed, see above error");

View File

@ -32,7 +32,7 @@ const createClusterInjectable = getInjectable({
createVersionDetector: di.inject(createVersionDetectorInjectable),
};
return (model) => new Cluster(dependencies, model);
return (model, configData) => new Cluster(dependencies, model, configData);
},
injectionToken: createClusterInjectionToken,

View File

@ -41,7 +41,7 @@ function getContexts(config: KubeConfig): Map<string, Option> {
splitConfig(config)
.map(({ config, error }) => [config.currentContext, {
config,
error,
error: error?.toString(),
}]),
);
}

View File

@ -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>

View File

@ -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),
}),
});

View File

@ -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}
>

View File

@ -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

View File

@ -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;
}

View File

@ -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 allowedResourcesInjectable from "../../../common/cluster-store/allowed-resources.injectable";
import { computed } from "mobx";
import type { Cluster } from "../../../common/cluster/cluster";
import createClusterInjectable from "../../create-cluster/create-cluster.injectable";
import hostedClusterInjectable from "../../../common/cluster-store/hosted-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 hostedClusterIdInjectable from "../../../common/cluster-store/hosted-cluster-id.injectable";
import legacyOnChannelListenInjectable from "../../ipc/legacy-channel-listen.injectable";
import currentRouteComponentInjectable from "../../routes/current-route-component.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");
});
});
});

View File

@ -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),
}),
});

View File

@ -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.");
});
},
});

View File

@ -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;

View File

@ -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);