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

Fix cluster connect statuses being intertwined

- Broadcast on a signle channel and add clusterId as data argument

- Make everything else computed based on the current props.cluster value

- Fix view staying on cluster connect screen after disconnect

- Initially clicking on a disconnected cluster should not immediately
  nativate to the Catalog if currently viewing a connected cluster

- Make route-path-parameters more type correct by not using keyedSingleton

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2022-04-05 11:24:50 -04:00
parent a9f4bcecb2
commit 853573afcb
28 changed files with 361 additions and 197 deletions

View File

@ -106,12 +106,8 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
return this.clusters.size > 0; return this.clusters.size > 0;
} }
getById(id: ClusterId | undefined): Cluster | undefined { getById(id: ClusterId): Cluster | undefined {
if (id) { return this.clusters.get(id);
return this.clusters.get(id);
}
return undefined;
} }
addCluster(clusterOrModel: ClusterModel | Cluster): Cluster { addCluster(clusterOrModel: ClusterModel | Cluster): Cluster {

View File

@ -120,7 +120,7 @@ export enum ClusterStatus {
} }
/** /**
* The message format for the "cluster:<cluster-id>:connection-update" channels * The message format for the "cluster:connection-update" channel
*/ */
export interface KubeAuthUpdate { export interface KubeAuthUpdate {
message: string; message: string;

View File

@ -625,7 +625,7 @@ export class Cluster implements ClusterModel, ClusterState {
const update: KubeAuthUpdate = { message, isError }; const update: KubeAuthUpdate = { message, isError };
this.dependencies.logger.debug(`[CLUSTER]: broadcasting connection update`, { ...update, meta: this.getMeta() }); this.dependencies.logger.debug(`[CLUSTER]: broadcasting connection update`, { ...update, meta: this.getMeta() });
broadcastMessage(`cluster:${this.id}:connection-update`, update); broadcastMessage(`cluster:connection-update`, this.id, update);
} }
protected async getAllowedNamespaces(proxyConfig: KubeConfig) { protected async getAllowedNamespaces(proxyConfig: KubeConfig) {

View File

@ -5,15 +5,20 @@
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import { computed } from "mobx"; import { computed } from "mobx";
import { frontEndRouteInjectionToken } from "../../front-end-route-injection-token"; import { frontEndRouteInjectionToken } from "../../front-end-route-injection-token";
import type { Route } from "../../front-end-route-injection-token";
export interface ClusterViewRouteParams {
clusterId: string;
}
const clusterViewRouteInjectable = getInjectable({ const clusterViewRouteInjectable = getInjectable({
id: "cluster-view-route", id: "cluster-view-route",
instantiate: () => ({ instantiate: () => ({
path: "/cluster/:clusterId", path: "/cluster/:clusterId" as const,
clusterFrame: false, clusterFrame: false,
isEnabled: computed(() => true), isEnabled: computed(() => true),
}), }) as Route<ClusterViewRouteParams>,
injectionToken: frontEndRouteInjectionToken, injectionToken: frontEndRouteInjectionToken,
}); });

View File

@ -219,7 +219,7 @@ describe("kube auth proxy tests", () => {
mockWaitUntilUsed.mockReturnValueOnce(Promise.resolve()); mockWaitUntilUsed.mockReturnValueOnce(Promise.resolve());
const cluster = createCluster({ const cluster = createCluster({
id: "foobar", id: "some-cluster-id",
kubeConfigPath: "minikube-config.yml", kubeConfigPath: "minikube-config.yml",
contextName: "minikube", contextName: "minikube",
}, { }, {
@ -233,34 +233,34 @@ describe("kube auth proxy tests", () => {
await proxy.run(); await proxy.run();
listeners.emit("error", { message: "foobarbat" }); listeners.emit("error", { message: "foobarbat" });
expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "foobarbat", isError: true }); expect(mockBroadcastIpc).toBeCalledWith("cluster:connection-update", "some-cluster-id", { message: "foobarbat", isError: true });
}); });
it("should call spawn and broadcast exit", async () => { it("should call spawn and broadcast exit", async () => {
await proxy.run(); await proxy.run();
listeners.emit("exit", 0); listeners.emit("exit", 0);
expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "proxy exited with code: 0", isError: false }); expect(mockBroadcastIpc).toBeCalledWith("cluster:connection-update", "some-cluster-id", { message: "proxy exited with code: 0", isError: false });
}); });
it("should call spawn and broadcast errors from stderr", async () => { it("should call spawn and broadcast errors from stderr", async () => {
await proxy.run(); await proxy.run();
listeners.emit("stderr/data", "an error"); listeners.emit("stderr/data", "an error");
expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "an error", isError: true }); expect(mockBroadcastIpc).toBeCalledWith("cluster:connection-update", "some-cluster-id", { message: "an error", isError: true });
}); });
it("should call spawn and broadcast stdout serving info", async () => { it("should call spawn and broadcast stdout serving info", async () => {
await proxy.run(); await proxy.run();
expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "Authentication proxy started", isError: false }); expect(mockBroadcastIpc).toBeCalledWith("cluster:connection-update", "some-cluster-id", { message: "Authentication proxy started", isError: false });
}); });
it("should call spawn and broadcast stdout other info", async () => { it("should call spawn and broadcast stdout other info", async () => {
await proxy.run(); await proxy.run();
listeners.emit("stdout/data", "some info"); listeners.emit("stdout/data", "some info");
expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "some info", isError: false }); expect(mockBroadcastIpc).toBeCalledWith("cluster:connection-update", "some-cluster-id", { message: "some info", isError: false });
}); });
}); });
}); });

View File

@ -13,7 +13,7 @@ const clusterManagerInjectable = getInjectable({
instantiate: (di) => { instantiate: (di) => {
const clusterManager = new ClusterManager({ const clusterManager = new ClusterManager({
store: di.inject(clusterStoreInjectable), store: di.inject(clusterStoreInjectable),
catalogEntityRegistry: di.inject(catalogEntityRegistryInjectable), entityRegistry: di.inject(catalogEntityRegistryInjectable),
}); });
clusterManager.init(); clusterManager.init();

View File

@ -23,8 +23,8 @@ const logPrefix = "[CLUSTER-MANAGER]:";
const lensSpecificClusterStatuses: Set<string> = new Set(Object.values(LensKubernetesClusterStatus)); const lensSpecificClusterStatuses: Set<string> = new Set(Object.values(LensKubernetesClusterStatus));
interface Dependencies { interface Dependencies {
store: ClusterStore; readonly store: ClusterStore;
catalogEntityRegistry: CatalogEntityRegistry; readonly entityRegistry: CatalogEntityRegistry;
} }
export class ClusterManager { export class ClusterManager {
@ -32,7 +32,7 @@ export class ClusterManager {
@observable visibleCluster: ClusterId | undefined = undefined; @observable visibleCluster: ClusterId | undefined = undefined;
constructor(private dependencies: Dependencies) { constructor(protected readonly dependencies: Dependencies) {
makeObservable(this); makeObservable(this);
} }
@ -52,12 +52,12 @@ export class ClusterManager {
); );
reaction( reaction(
() => this.dependencies.catalogEntityRegistry.filterItemsByPredicate(isKubernetesCluster), () => this.dependencies.entityRegistry.filterItemsByPredicate(isKubernetesCluster),
entities => this.syncClustersFromCatalog(entities), entities => this.syncClustersFromCatalog(entities),
); );
reaction(() => [ reaction(() => [
this.dependencies.catalogEntityRegistry.filterItemsByPredicate(isKubernetesCluster), this.dependencies.entityRegistry.filterItemsByPredicate(isKubernetesCluster),
this.visibleCluster, this.visibleCluster,
] as const, ([entities, visibleCluster]) => { ] as const, ([entities, visibleCluster]) => {
for (const entity of entities) { for (const entity of entities) {
@ -71,7 +71,7 @@ export class ClusterManager {
observe(this.deleting, change => { observe(this.deleting, change => {
if (change.type === "add") { if (change.type === "add") {
this.updateEntityStatus(this.dependencies.catalogEntityRegistry.findById(change.newValue) as KubernetesCluster); this.updateEntityStatus(this.dependencies.entityRegistry.findById(change.newValue) as KubernetesCluster);
} }
}); });
@ -89,13 +89,13 @@ export class ClusterManager {
} }
protected updateEntityFromCluster(cluster: Cluster) { protected updateEntityFromCluster(cluster: Cluster) {
const index = this.dependencies.catalogEntityRegistry.items.findIndex((entity) => entity.getId() === cluster.id); const index = this.dependencies.entityRegistry.items.findIndex((entity) => entity.getId() === cluster.id);
if (index === -1) { if (index === -1) {
return; return;
} }
const entity = this.dependencies.catalogEntityRegistry.items[index] as KubernetesCluster; const entity = this.dependencies.entityRegistry.items[index] as KubernetesCluster;
this.updateEntityStatus(entity, cluster); this.updateEntityStatus(entity, cluster);
@ -136,7 +136,7 @@ export class ClusterManager {
cluster.preferences.icon = undefined; cluster.preferences.icon = undefined;
} }
this.dependencies.catalogEntityRegistry.items.splice(index, 1, entity); this.dependencies.entityRegistry.items.splice(index, 1, entity);
} }
@action @action
@ -279,7 +279,13 @@ export class ClusterManager {
return cluster; return cluster;
} }
return this.dependencies.store.getById(getClusterIdFromHost(req.headers.host)); const clusterId = getClusterIdFromHost(req.headers.host);
if (!clusterId) {
return undefined;
}
return this.dependencies.store.getById(clusterId);
}; };
} }

View File

@ -15,7 +15,15 @@ const getActiveClusterEntityInjectable = getInjectable({
const store = di.inject(clusterStoreInjectable); const store = di.inject(clusterStoreInjectable);
const entityRegistry = di.inject(catalogEntityRegistryInjectable); const entityRegistry = di.inject(catalogEntityRegistryInjectable);
return () => store.getById(entityRegistry.activeEntity?.getId()); return () => {
const entityId = entityRegistry.activeEntity?.getId();
if (!entityId) {
return undefined;
}
return store.getById(entityId);
};
}, },
}); });

View File

@ -13,6 +13,10 @@ const hostedClusterInjectable = getInjectable({
const hostedClusterId = di.inject(hostedClusterIdInjectable); const hostedClusterId = di.inject(hostedClusterIdInjectable);
const store = di.inject(clusterStoreInjectable); const store = di.inject(clusterStoreInjectable);
if (!hostedClusterId) {
return undefined;
}
return store.getById(hostedClusterId); return store.getById(hostedClusterId);
}, },
}); });

View File

@ -12,7 +12,7 @@ const catalogRouteParametersInjectable = getInjectable({
instantiate: (di) => { instantiate: (di) => {
const route = di.inject(catalogRouteInjectable); const route = di.inject(catalogRouteInjectable);
const pathParameters = di.inject(routePathParametersInjectable, route); const pathParameters = di.inject(routePathParametersInjectable)(route);
return { return {
group: computed(() => pathParameters.get().group), group: computed(() => pathParameters.get().group),

View File

@ -56,8 +56,8 @@ interface Dependencies {
customCategoryViews: IComputedValue<Map<string, Map<string, RegisteredCustomCategoryViewDecl>>>; customCategoryViews: IComputedValue<Map<string, Map<string, RegisteredCustomCategoryViewDecl>>>;
emitEvent: (event: AppEvent) => void; emitEvent: (event: AppEvent) => void;
routeParameters: { routeParameters: {
group: IComputedValue<string>; group: IComputedValue<string | undefined>;
kind: IComputedValue<string>; kind: IComputedValue<string | undefined>;
}; };
navigateToCatalog: NavigateToCatalog; navigateToCatalog: NavigateToCatalog;
hotbarStore: HotbarStore; hotbarStore: HotbarStore;

View File

@ -27,8 +27,8 @@ enum columnId {
} }
interface Dependencies { interface Dependencies {
group: IComputedValue<string>; group: IComputedValue<string | undefined>;
name: IComputedValue<string>; name: IComputedValue<string | undefined>;
apiManager: ApiManager; apiManager: ApiManager;
customResourceDefinitionStore: CustomResourceDefinitionStore; customResourceDefinitionStore: CustomResourceDefinitionStore;
} }
@ -41,7 +41,14 @@ class NonInjectedCustomResources extends React.Component<Dependencies> {
} }
@computed get crd() { @computed get crd() {
return this.props.customResourceDefinitionStore.getByGroup(this.props.group.get(), this.props.name.get()); const group = this.props.group.get();
const name = this.props.name.get();
if (!group || !name) {
return undefined;
}
return this.props.customResourceDefinitionStore.getByGroup(group, name);
} }
@computed get store() { @computed get store() {

View File

@ -12,7 +12,7 @@ const customResourcesRouteParametersInjectable = getInjectable({
instantiate: (di) => { instantiate: (di) => {
const route = di.inject(customResourcesRouteInjectable); const route = di.inject(customResourcesRouteInjectable);
const pathParameters = di.inject(routePathParametersInjectable, route); const pathParameters = di.inject(routePathParametersInjectable)(route);
return { return {
group: computed(() => pathParameters.get().group), group: computed(() => pathParameters.get().group),

View File

@ -12,7 +12,7 @@ const entitySettingsRouteParametersInjectable = getInjectable({
instantiate: (di) => { instantiate: (di) => {
const route = di.inject(entitySettingsRouteInjectable); const route = di.inject(entitySettingsRouteInjectable);
const pathParameters = di.inject(routePathParametersInjectable, route); const pathParameters = di.inject(routePathParametersInjectable)(route);
return { return {
entityId: computed(() => pathParameters.get().entityId), entityId: computed(() => pathParameters.get().entityId),

View File

@ -24,7 +24,7 @@ import catalogEntityRegistryInjectable from "../../api/catalog/entity/registry.i
import observableHistoryInjectable from "../../navigation/observable-history.injectable"; import observableHistoryInjectable from "../../navigation/observable-history.injectable";
interface Dependencies { interface Dependencies {
entityId: IComputedValue<string>; entityId: IComputedValue<string | undefined>;
entityRegistry: CatalogEntityRegistry; entityRegistry: CatalogEntityRegistry;
observableHistory: ObservableHistory<unknown>; observableHistory: ObservableHistory<unknown>;
} }
@ -54,8 +54,15 @@ class NonInjectedEntitySettings extends React.Component<Dependencies> {
return this.props.entityId.get(); return this.props.entityId.get();
} }
@computed
get entity() { get entity() {
return this.props.entityRegistry.getById(this.entityId); const { entityId } = this;
if (!entityId) {
return undefined;
}
return this.props.entityRegistry.getById(entityId);
} }
get menuItems() { get menuItems() {

View File

@ -12,7 +12,7 @@ const helmChartsRouteParametersInjectable = getInjectable({
instantiate: (di) => { instantiate: (di) => {
const route = di.inject(helmChartsRouteInjectable); const route = di.inject(helmChartsRouteInjectable);
const pathParameters = di.inject(routePathParametersInjectable, route); const pathParameters = di.inject(routePathParametersInjectable)(route);
return { return {
chartName: computed(() => pathParameters.get().chartName), chartName: computed(() => pathParameters.get().chartName),

View File

@ -32,8 +32,8 @@ enum columnId {
interface Dependencies { interface Dependencies {
routeParameters: { routeParameters: {
chartName: IComputedValue<string>; chartName: IComputedValue<string | undefined>;
repo: IComputedValue<string>; repo: IComputedValue<string | undefined>;
}; };
navigateToHelmCharts: NavigateToHelmCharts; navigateToHelmCharts: NavigateToHelmCharts;

View File

@ -12,7 +12,7 @@ const helmReleasesRouteParametersInjectable = getInjectable({
instantiate: (di) => { instantiate: (di) => {
const route = di.inject(helmReleasesRouteInjectable); const route = di.inject(helmReleasesRouteInjectable);
const pathParameters = di.inject(routePathParametersInjectable, route); const pathParameters = di.inject(routePathParametersInjectable)(route);
return { return {
namespace: computed(() => pathParameters.get().namespace), namespace: computed(() => pathParameters.get().namespace),

View File

@ -40,7 +40,7 @@ interface Dependencies {
releases: IComputedValue<RemovableHelmRelease[]>; releases: IComputedValue<RemovableHelmRelease[]>;
releasesArePending: IComputedValue<boolean>; releasesArePending: IComputedValue<boolean>;
selectNamespace: (namespace: string) => void; selectNamespace: (namespace: string) => void;
namespace: IComputedValue<string>; namespace: IComputedValue<string | undefined>;
navigateToHelmReleases: NavigateToHelmReleases; navigateToHelmReleases: NavigateToHelmReleases;
} }
@ -213,20 +213,12 @@ class NonInjectedHelmReleases extends Component<Dependencies> {
} }
} }
export const HelmReleases = withInjectables<Dependencies>( export const HelmReleases = withInjectables<Dependencies>(NonInjectedHelmReleases, {
NonInjectedHelmReleases, getProps: (di) => ({
releases: di.inject(removableReleasesInjectable),
{ releasesArePending: di.inject(releasesInjectable).pending,
getProps: (di) => { selectNamespace: di.inject(namespaceStoreInjectable).selectNamespaces,
const routeParameters = di.inject(helmReleasesRouteParametersInjectable); navigateToHelmReleases: di.inject(navigateToHelmReleasesInjectable),
...di.inject(helmReleasesRouteParametersInjectable),
return { }),
releases: di.inject(removableReleasesInjectable), });
releasesArePending: di.inject(releasesInjectable).pending,
selectNamespace: di.inject(namespaceStoreInjectable).selectNamespaces,
navigateToHelmReleases: di.inject(navigateToHelmReleasesInjectable),
namespace: routeParameters.namespace,
};
},
},
);

View File

@ -12,7 +12,7 @@ const portForwardsRouteParametersInjectable = getInjectable({
instantiate: (di) => { instantiate: (di) => {
const route = di.inject(portForwardsRouteInjectable); const route = di.inject(portForwardsRouteInjectable);
const pathParameters = di.inject(routePathParametersInjectable, route); const pathParameters = di.inject(routePathParametersInjectable)(route);
return { return {
forwardport: computed(() => pathParameters.get().forwardport), forwardport: computed(() => pathParameters.get().forwardport),

View File

@ -32,7 +32,7 @@ enum columnId {
interface Dependencies { interface Dependencies {
portForwardStore: PortForwardStore; portForwardStore: PortForwardStore;
forwardport: IComputedValue<string>; forwardport: IComputedValue<string | undefined>;
navigateToPortForwards: NavigateToPortForwards; navigateToPortForwards: NavigateToPortForwards;
} }
@ -158,19 +158,11 @@ class NonInjectedPortForwards extends React.Component<Dependencies> {
} }
} }
export const PortForwards = withInjectables<Dependencies>( export const PortForwards = withInjectables<Dependencies>(NonInjectedPortForwards, {
NonInjectedPortForwards, getProps: (di) => ({
portForwardStore: di.inject(portForwardStoreInjectable),
{ navigateToPortForwards: di.inject(navigateToPortForwardsInjectable),
getProps: (di) => { ...di.inject(portForwardsRouteParametersInjectable),
const routeParameters = di.inject(portForwardsRouteParametersInjectable); }),
});
return {
portForwardStore: di.inject(portForwardStoreInjectable),
forwardport: routeParameters.forwardport,
navigateToPortForwards: di.inject(navigateToPortForwardsInjectable),
};
},
},
);

View File

@ -0,0 +1,57 @@
/**
* 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 { action, reaction } from "mobx";
import type { ClusterStore } from "../../../common/cluster-store/cluster-store";
import clusterStoreInjectable from "../../../common/cluster-store/cluster-store.injectable";
import type { ClusterId, KubeAuthUpdate } from "../../../common/cluster-types";
import ipcRendererInjectable from "../../app-paths/get-value-from-registered-channel/ipc-renderer/ipc-renderer.injectable";
import type { ClusterConnectionStatusState } from "./cluster-status.state.injectable";
function computeRisingEdgeForClusterDisconnect(store: ClusterStore, onNewlyDisconnected: (clusterId: ClusterId) => void) {
const disconnectedStateComputer = () => store.clustersList.map(cluster => [cluster.id, cluster.disconnected] as const);
const state = new Map(disconnectedStateComputer());
reaction(
disconnectedStateComputer,
(disconnectedStates) => {
for (const [clusterId, isDisconnected] of disconnectedStates) {
if (state.get(clusterId) === isDisconnected) {
// do nothing
} else {
state.set(clusterId, isDisconnected); // save the new state
if (isDisconnected) {
// If the new value is `true` then the previous value was falsy and this is the rising edge.
onNewlyDisconnected(clusterId);
}
}
}
},
);
}
// This needs to be an `init` function to bypass a bug in the setup -> injectable -> setup path
const initClusterStatusWatcherInjectable = getInjectable({
id: "cluster-status-watcher",
instantiate: (di) => {
const ipcRenderer = di.inject(ipcRendererInjectable);
const clusterStore = di.inject(clusterStoreInjectable);
return (state: ClusterConnectionStatusState) => {
ipcRenderer.on("cluster:connection-update", (evt, clusterId: ClusterId, update: KubeAuthUpdate) => {
state.forCluster(clusterId).appendAuthUpdate(update);
});
computeRisingEdgeForClusterDisconnect(clusterStore, action((clusterId) => {
const forCluster = state.forCluster(clusterId);
forCluster.clearReconnectingState();
forCluster.resetAuthOutput();
}));
};
},
});
export default initClusterStatusWatcherInjectable;

View File

@ -0,0 +1,73 @@
/**
* 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 type { IComputedValue } from "mobx";
import { action, computed, observable } from "mobx";
import type { ClusterId, KubeAuthUpdate } from "../../../common/cluster-types";
import loggerInjectable from "../../../common/logger.injectable";
import { getOrInsert, hasTypedProperty, isBoolean, isObject, isString } from "../../utils";
import initClusterStatusWatcherInjectable from "./cluster-status-watcher.injectable";
export interface ClusterConnectionStatus {
readonly authOutput: IComputedValue<KubeAuthUpdate[]>;
readonly hasErrorOutput: IComputedValue<boolean>;
readonly isReconnecting: IComputedValue<boolean>;
resetAuthOutput(): void;
setAsReconnecting(): void;
clearReconnectingState(): void;
appendAuthUpdate(update: unknown): void;
}
export interface ClusterConnectionStatusState {
forCluster(clusterId: ClusterId): Readonly<ClusterConnectionStatus>;
}
const clusterConnectionStatusStateInjectable = getInjectable({
id: "cluster-connection-status-state",
instantiate: (di) => {
const authOutputs = observable.map<ClusterId, KubeAuthUpdate[]>();
const reconnecting = observable.set<ClusterId>();
const initWatcher = di.inject(initClusterStatusWatcherInjectable);
const logger = di.inject(loggerInjectable);
const state: ClusterConnectionStatusState = {
forCluster: (clusterId) => {
const authOutput = computed(() => authOutputs.get(clusterId) ?? []);
return {
authOutput,
isReconnecting: computed(() => reconnecting.has(clusterId)),
hasErrorOutput: computed(() => authOutput.get().some(output => output.isError)),
resetAuthOutput: action(() => {
authOutputs.delete(clusterId);
}),
setAsReconnecting: action(() => {
reconnecting.add(clusterId);
}),
clearReconnectingState: action(() => {
reconnecting.delete(clusterId);
}),
appendAuthUpdate: action((update) => {
if (
isObject(update)
&& hasTypedProperty(update, "message", isString)
&& hasTypedProperty(update, "isError", isBoolean)
) {
getOrInsert(authOutputs, clusterId, []).push(update);
} else {
logger.warn(`[CLUSTER]: invalid connection update`, { update, clusterId });
}
}),
};
},
};
initWatcher(state);
return state;
},
});
export default clusterConnectionStatusStateInjectable;

View File

@ -5,23 +5,23 @@
import styles from "./cluster-status.module.scss"; import styles from "./cluster-status.module.scss";
import { computed, observable, makeObservable } from "mobx"; import { runInAction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { observer } from "mobx-react";
import React from "react"; import React from "react";
import { ipcRendererOn } from "../../../common/ipc";
import type { Cluster } from "../../../common/cluster/cluster"; import type { Cluster } from "../../../common/cluster/cluster";
import type { IClassName } from "../../utils"; import type { IClassName } from "../../utils";
import { isBoolean, hasTypedProperty, isObject, isString, cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { Button } from "../button"; import { Button } from "../button";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import type { KubeAuthUpdate } from "../../../common/cluster-types";
import type { CatalogEntityRegistry } from "../../api/catalog/entity/registry"; import type { CatalogEntityRegistry } from "../../api/catalog/entity/registry";
import { requestClusterActivation } from "../../ipc"; import { requestClusterActivation } from "../../ipc";
import type { NavigateToEntitySettings } from "../../../common/front-end-routing/routes/entity-settings/navigate-to-entity-settings.injectable"; import type { NavigateToEntitySettings } from "../../../common/front-end-routing/routes/entity-settings/navigate-to-entity-settings.injectable";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import navigateToEntitySettingsInjectable from "../../../common/front-end-routing/routes/entity-settings/navigate-to-entity-settings.injectable"; import navigateToEntitySettingsInjectable from "../../../common/front-end-routing/routes/entity-settings/navigate-to-entity-settings.injectable";
import catalogEntityRegistryInjectable from "../../api/catalog/entity/registry.injectable"; import catalogEntityRegistryInjectable from "../../api/catalog/entity/registry.injectable";
import type { ClusterConnectionStatus } from "./cluster-status.state.injectable";
import clusterConnectionStatusStateInjectable from "./cluster-status.state.injectable";
export interface ClusterStatusProps { export interface ClusterStatusProps {
className?: IClassName; className?: IClassName;
@ -31,89 +31,58 @@ export interface ClusterStatusProps {
interface Dependencies { interface Dependencies {
navigateToEntitySettings: NavigateToEntitySettings; navigateToEntitySettings: NavigateToEntitySettings;
entityRegistry: CatalogEntityRegistry; entityRegistry: CatalogEntityRegistry;
state: ClusterConnectionStatus;
} }
@observer const NonInjectedClusterStatus = observer((props: ClusterStatusProps & Dependencies) => {
class NonInjectedClusterStatus extends React.Component<ClusterStatusProps & Dependencies> { const {
@observable authOutput: KubeAuthUpdate[] = []; cluster,
@observable isReconnecting = false; navigateToEntitySettings,
state,
className,
entityRegistry,
} = props;
const entity = entityRegistry.getById(cluster.id);
const clusterName = entity?.getName() ?? cluster.name;
constructor(props: ClusterStatusProps & Dependencies) { const reconnect = async () => {
super(props); runInAction(() => {
makeObservable(this); state.resetAuthOutput();
} state.setAsReconnecting();
});
get cluster(): Cluster {
return this.props.cluster;
}
@computed get entity() {
return this.props.entityRegistry.getById(this.cluster.id);
}
@computed get hasErrors(): boolean {
return this.authOutput.some(({ isError }) => isError);
}
componentDidMount() {
disposeOnUnmount(this, [
ipcRendererOn(`cluster:${this.cluster.id}:connection-update`, (evt, res: unknown) => {
if (
isObject(res)
&& hasTypedProperty(res, "message", isString)
&& hasTypedProperty(res, "isError", isBoolean)
) {
this.authOutput.push(res);
} else {
console.warn(`Got invalid connection update for ${this.cluster.id}`, { update: res });
}
}),
]);
}
componentDidUpdate(prevProps: Readonly<ClusterStatusProps>): void {
if (prevProps.cluster.id !== this.props.cluster.id) {
this.isReconnecting = false;
this.authOutput = [];
}
}
reconnect = async () => {
this.authOutput = [];
this.isReconnecting = true;
try { try {
await requestClusterActivation(this.cluster.id, true); await requestClusterActivation(cluster.id, true);
} catch (error) { } catch (error) {
this.authOutput.push({ state.appendAuthUpdate({
message: String(error), message: String(error),
isError: true, isError: true,
}); });
} finally { } finally {
this.isReconnecting = false; state.clearReconnectingState();
} }
}; };
manageProxySettings = () => { const manageProxySettings = () => navigateToEntitySettings(cluster.id, "proxy");
this.props.navigateToEntitySettings(this.cluster.id, "proxy");
};
renderAuthenticationOutput() { const renderAuthenticationOutput = () => {
return ( return (
<pre> <pre>
{ {
this.authOutput.map(({ message, isError }, index) => ( state.authOutput
<p key={index} className={cssNames({ error: isError })}> .get()
{message.trim()} .map(({ message, isError }, index) => (
</p> <p key={index} className={cssNames({ error: isError })}>
)) {message.trim()}
</p>
))
} }
</pre> </pre>
); );
} };
renderStatusIcon() { const renderStatusIcon = () => {
if (this.hasErrors) { if (state.hasErrorOutput.get()) {
return <Icon material="cloud_off" className={styles.icon} />; return <Icon material="cloud_off" className={styles.icon} />;
} }
@ -122,28 +91,28 @@ class NonInjectedClusterStatus extends React.Component<ClusterStatusProps & Depe
<Spinner singleColor={false} className={styles.spinner} /> <Spinner singleColor={false} className={styles.spinner} />
<pre className="kube-auth-out"> <pre className="kube-auth-out">
<p> <p>
{this.isReconnecting ? "Reconnecting" : "Connecting"} {state.isReconnecting.get() ? "Reconnecting" : "Connecting"}
&hellip; &hellip;
</p> </p>
</pre> </pre>
</> </>
); );
} };
renderReconnectionHelp() { const renderReconnectionHelp = () => {
if (this.hasErrors && !this.isReconnecting) { if (state.hasErrorOutput.get() && !state.isReconnecting.get()) {
return ( return (
<> <>
<Button <Button
primary primary
label="Reconnect" label="Reconnect"
className="box center" className="box center"
onClick={this.reconnect} onClick={reconnect}
waiting={this.isReconnecting} waiting={state.isReconnecting.get()}
/> />
<a <a
className="box center interactive" className="box center interactive"
onClick={this.manageProxySettings} onClick={manageProxySettings}
> >
Manage Proxy Settings Manage Proxy Settings
</a> </a>
@ -152,26 +121,25 @@ class NonInjectedClusterStatus extends React.Component<ClusterStatusProps & Depe
} }
return undefined; return undefined;
} };
render() { return (
return ( <div className={cssNames(styles.status, "flex column box center align-center justify-center", className)}>
<div className={cssNames(styles.status, "flex column box center align-center justify-center", this.props.className)}> <div className="flex items-center column gaps">
<div className="flex items-center column gaps"> <h2>{clusterName}</h2>
<h2>{this.entity?.getName() ?? this.cluster.name}</h2> {renderStatusIcon()}
{this.renderStatusIcon()} {renderAuthenticationOutput()}
{this.renderAuthenticationOutput()} {renderReconnectionHelp()}
{this.renderReconnectionHelp()}
</div>
</div> </div>
); </div>
} );
} });
export const ClusterStatus = withInjectables<Dependencies, ClusterStatusProps>(NonInjectedClusterStatus, { export const ClusterStatus = withInjectables<Dependencies, ClusterStatusProps>(NonInjectedClusterStatus, {
getProps: (di, props) => ({ getProps: (di, props) => ({
...props, ...props,
navigateToEntitySettings: di.inject(navigateToEntitySettingsInjectable), navigateToEntitySettings: di.inject(navigateToEntitySettingsInjectable),
entityRegistry: di.inject(catalogEntityRegistryInjectable), entityRegistry: di.inject(catalogEntityRegistryInjectable),
state: di.inject(clusterConnectionStatusStateInjectable).forCluster(props.cluster.id),
}), }),
}); });

View File

@ -12,7 +12,7 @@ const clusterViewRouteParametersInjectable = getInjectable({
instantiate: (di) => { instantiate: (di) => {
const route = di.inject(clusterViewRouteInjectable); const route = di.inject(clusterViewRouteInjectable);
const pathParameters = di.inject(routePathParametersInjectable, route); const pathParameters = di.inject(routePathParametersInjectable)(route);
return { return {
clusterId: computed(() => pathParameters.get().clusterId), clusterId: computed(() => pathParameters.get().clusterId),

View File

@ -6,12 +6,12 @@
import "./cluster-view.scss"; import "./cluster-view.scss";
import React from "react"; import React from "react";
import type { IComputedValue } from "mobx"; import type { IComputedValue } from "mobx";
import { computed, makeObservable, reaction } from "mobx"; import { when, computed, makeObservable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { ClusterStatus } from "./cluster-status"; import { ClusterStatus } from "./cluster-status";
import type { ClusterFrameHandler } from "./cluster-frame-handler"; import type { ClusterFrameHandler } from "./cluster-frame-handler";
import type { Cluster } from "../../../common/cluster/cluster"; import type { Cluster } from "../../../common/cluster/cluster";
import { ClusterStore } from "../../../common/cluster-store/cluster-store"; import type { ClusterStore } from "../../../common/cluster-store/cluster-store";
import { requestClusterActivation } from "../../ipc"; import { requestClusterActivation } from "../../ipc";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import type { NavigateToCatalog } from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable"; import type { NavigateToCatalog } from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";
@ -20,17 +20,24 @@ import clusterViewRouteParametersInjectable from "./cluster-view-route-parameter
import clusterFrameHandlerInjectable from "./cluster-frame-handler.injectable"; import clusterFrameHandlerInjectable from "./cluster-frame-handler.injectable";
import type { CatalogEntityRegistry } from "../../api/catalog/entity/registry"; import type { CatalogEntityRegistry } from "../../api/catalog/entity/registry";
import catalogEntityRegistryInjectable from "../../api/catalog/entity/registry.injectable"; import catalogEntityRegistryInjectable from "../../api/catalog/entity/registry.injectable";
import type { ClusterConnectionStatusState } from "./cluster-status.state.injectable";
import clusterConnectionStatusStateInjectable from "./cluster-status.state.injectable";
import clusterStoreInjectable from "../../../common/cluster-store/cluster-store.injectable";
import type { Disposer } from "../../utils";
import { disposer } from "../../utils";
interface Dependencies { interface Dependencies {
clusterId: IComputedValue<string>; clusterId: IComputedValue<string | undefined>;
clusterFrames: ClusterFrameHandler; clusterFrames: ClusterFrameHandler;
navigateToCatalog: NavigateToCatalog; navigateToCatalog: NavigateToCatalog;
entityRegistry: CatalogEntityRegistry; entityRegistry: CatalogEntityRegistry;
clusterConnectionStatusState: ClusterConnectionStatusState;
clusterStore: ClusterStore;
} }
@observer @observer
class NonInjectedClusterView extends React.Component<Dependencies> { class NonInjectedClusterView extends React.Component<Dependencies> {
private readonly store = ClusterStore.getInstance(); private navigateToClusterDisposer?: Disposer;
constructor(props: Dependencies) { constructor(props: Dependencies) {
super(props); super(props);
@ -42,13 +49,30 @@ class NonInjectedClusterView extends React.Component<Dependencies> {
} }
@computed get cluster(): Cluster | undefined { @computed get cluster(): Cluster | undefined {
return this.store.getById(this.clusterId); const { clusterId } = this;
if (!clusterId) {
return undefined;
}
return this.props.clusterStore.getById(clusterId);
} }
private readonly isViewLoaded = computed(() => this.props.clusterFrames.hasLoadedView(this.clusterId), { private readonly isViewLoaded = computed(
keepAlive: true, () => {
requiresReaction: true, const { clusterId } = this;
});
if (!clusterId) {
return false;
}
return this.props.clusterFrames.hasLoadedView(clusterId);
},
{
keepAlive: true,
requiresReaction: true,
},
);
@computed get isReady(): boolean { @computed get isReady(): boolean {
const { cluster } = this; const { cluster } = this;
@ -68,8 +92,13 @@ class NonInjectedClusterView extends React.Component<Dependencies> {
bindEvents() { bindEvents() {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
reaction(() => this.clusterId, async (clusterId) => { reaction(() => this.clusterId, async (clusterId) => {
// TODO: replace with better handling this.navigateToClusterDisposer?.();
if (clusterId && !this.props.entityRegistry.getById(clusterId)) {
if (!clusterId) {
return;
}
if (!this.props.entityRegistry.getById(clusterId)) {
return this.props.navigateToCatalog(); // redirect to catalog when the clusterId does not correspond to an entity return this.props.navigateToCatalog(); // redirect to catalog when the clusterId does not correspond to an entity
} }
@ -77,15 +106,30 @@ class NonInjectedClusterView extends React.Component<Dependencies> {
this.props.clusterFrames.initView(clusterId); this.props.clusterFrames.initView(clusterId);
requestClusterActivation(clusterId, false); // activate and fetch cluster's state from main requestClusterActivation(clusterId, false); // activate and fetch cluster's state from main
this.props.entityRegistry.activeEntity = clusterId; this.props.entityRegistry.activeEntity = clusterId;
const navigateToClusterDisposer = disposer(
when(
() => this.cluster?.disconnected === false,
() => {
navigateToClusterDisposer.push(when(
// The clusterId check makes sure that we are still talking about the same cluster
() => (this.cluster?.disconnected ?? true) && this.clusterId === clusterId,
() => {
this.props.navigateToCatalog();
},
));
},
),
);
this.navigateToClusterDisposer = navigateToClusterDisposer;
disposeOnUnmount(this, [
navigateToClusterDisposer,
]);
}, { }, {
fireImmediately: true, fireImmediately: true,
}), }),
reaction(() => [this.cluster?.ready, this.cluster?.disconnected], ([, disconnected]) => {
if (this.isViewLoaded.get() && disconnected) {
this.props.navigateToCatalog(); // redirect to catalog when active cluster get disconnected/not available
}
}),
]); ]);
} }
@ -114,6 +158,8 @@ export const ClusterView = withInjectables<Dependencies>(NonInjectedClusterView,
navigateToCatalog: di.inject(navigateToCatalogInjectable), navigateToCatalog: di.inject(navigateToCatalogInjectable),
clusterFrames: di.inject(clusterFrameHandlerInjectable), clusterFrames: di.inject(clusterFrameHandlerInjectable),
entityRegistry: di.inject(catalogEntityRegistryInjectable), entityRegistry: di.inject(catalogEntityRegistryInjectable),
clusterConnectionStatusState: di.inject(clusterConnectionStatusStateInjectable),
clusterStore: di.inject(clusterStoreInjectable),
}), }),
}); });

View File

@ -14,6 +14,7 @@ import type { CommandOverlay } from "./command-overlay.injectable";
import commandOverlayInjectable from "./command-overlay.injectable"; import commandOverlayInjectable from "./command-overlay.injectable";
import type { ipcRendererOn } from "../../../common/ipc"; import type { ipcRendererOn } from "../../../common/ipc";
import { broadcastMessage } from "../../../common/ipc"; import { broadcastMessage } from "../../../common/ipc";
import type { CatalogEntityRegistry } from "../../api/catalog/entity/registry";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import type { AddWindowEventListener } from "../../window/event-listener.injectable"; import type { AddWindowEventListener } from "../../window/event-listener.injectable";
import windowAddEventListenerInjectable from "../../window/event-listener.injectable"; import windowAddEventListenerInjectable from "../../window/event-listener.injectable";
@ -23,6 +24,7 @@ import hostedClusterIdInjectable from "../../cluster-frame-context/hosted-cluste
import isMacInjectable from "../../../common/vars/is-mac.injectable"; import isMacInjectable from "../../../common/vars/is-mac.injectable";
import legacyOnChannelListenInjectable from "../../ipc/legacy-channel-listen.injectable"; import legacyOnChannelListenInjectable from "../../ipc/legacy-channel-listen.injectable";
import { onKeyboardShortcut } from "../../utils/on-keyboard-shortcut"; import { onKeyboardShortcut } from "../../utils/on-keyboard-shortcut";
import catalogEntityRegistryInjectable from "../../api/catalog/entity/registry.injectable";
interface Dependencies { interface Dependencies {
addWindowEventListener: AddWindowEventListener; addWindowEventListener: AddWindowEventListener;
@ -31,6 +33,7 @@ interface Dependencies {
matchedClusterId: IComputedValue<ClusterId | undefined>; matchedClusterId: IComputedValue<ClusterId | undefined>;
isMac: boolean; isMac: boolean;
legacyOnChannelListen: typeof ipcRendererOn; legacyOnChannelListen: typeof ipcRendererOn;
entityRegistry: CatalogEntityRegistry;
} }
@observer @observer
@ -97,5 +100,6 @@ export const CommandContainer = withInjectables<Dependencies>(NonInjectedCommand
matchedClusterId: di.inject(matchedClusterIdInjectable), matchedClusterId: di.inject(matchedClusterIdInjectable),
isMac: di.inject(isMacInjectable), isMac: di.inject(isMacInjectable),
legacyOnChannelListen: di.inject(legacyOnChannelListenInjectable), legacyOnChannelListen: di.inject(legacyOnChannelListenInjectable),
entityRegistry: di.inject(catalogEntityRegistryInjectable),
}), }),
}); });

View File

@ -2,32 +2,31 @@
* Copyright (c) OpenLens Authors. All rights reserved. * Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import type { IComputedValue } from "mobx";
import { computed } from "mobx"; import { computed } from "mobx";
import { matchPath } from "react-router"; import { matchPath } from "react-router";
import currentPathInjectable from "./current-path.injectable"; import currentPathInjectable from "./current-path.injectable";
import type { Route } from "../../common/front-end-routing/front-end-route-injection-token"; import type { Route } from "../../common/front-end-routing/front-end-route-injection-token";
import { getOrInsertWith } from "../utils";
const routePathParametersInjectable = getInjectable({ const routePathParametersInjectable = getInjectable({
id: "route-path-parameters", id: "route-path-parameters",
instantiate: (di, route: Route<unknown>) => { instantiate: (di) => {
const currentPath = di.inject(currentPathInjectable); const currentPath = di.inject(currentPathInjectable);
const pathParametersCache = new Map<Route<unknown>, IComputedValue<Partial<Record<string, string>>>>();
// TODO: Reuse typing from route for accuracy return <Param>(route: Route<Param>): IComputedValue<Partial<Param>> => (
return computed((): Record<string, string> => { getOrInsertWith(pathParametersCache, route, () => computed(() => (
const match = matchPath(currentPath.get(), { console.log(currentPath.get()),
path: route.path, matchPath(currentPath.get(), {
exact: true, path: route.path,
}); exact: true,
})?.params ?? {}
return match ? match.params : {}; )))
}); );
}, },
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, route: Route<unknown>) => route,
}),
}); });
export default routePathParametersInjectable; export default routePathParametersInjectable;