Restructure to monorepo (#6907)

* wip: restructure to monorepo

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* refactor create-release-pr to a package

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* build fixes

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* github workflow fixes

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix typo

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* add webpack-env types to core

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix github workflows

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* refactor/fix integration tests

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* lint fix

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* yarn run dev

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* eslint settings for vscode

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* move templates to right package

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* open-lens build fixes

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* integration test fix

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix nx task dependencies

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* use bash shell for unit tests in test workflow

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix test:unit for windows

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix win-ca webpack error in open-lens

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix win-ca webpack error in open-lens

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix build:app on windows

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* remove ELECTRON_BUILDER_EXTRA_ARGS

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* sync src/ from master

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* remove Makefile from core

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen authored and GitHub committed 2023-01-24 10:46:26 -08:00
1 parent 0066ca4ea2
commit 2657df2293
3321 files changed
+28699 -11447

No files matched your search

@@ -0,0 +1,300 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { CatalogCategory } from "../catalog-entity";
import { KubernetesCluster, WebLink } from "../../../common/catalog-entities";
import { observable } from "mobx";
import type { CatalogCategoryRegistry } from "../../../common/catalog";
import { categoryVersion } from "../../../common/catalog";
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import type { CatalogEntityRegistry } from "../catalog/entity/registry";
import catalogEntityRegistryInjectable from "../catalog/entity/registry.injectable";
import catalogCategoryRegistryInjectable from "../../../common/catalog/category-registry.injectable";
class FooBarCategory extends CatalogCategory {
public readonly apiVersion = "entityRegistry.k8slens.dev/v1alpha1";
public readonly kind = "CatalogCategory";
public metadata = {
name: "FooBars",
icon: "broken",
};
public spec = {
group: "entity.k8slens.dev",
versions: [
categoryVersion("v1alpha1", WebLink),
],
names: {
kind: "FooBar",
},
};
}
const entity = new WebLink({
metadata: {
uid: "test",
name: "test-link",
source: "test",
labels: {},
},
spec: {
url: "https://k8slens.dev",
},
status: {
phase: "available",
},
});
const entity2 = new WebLink({
metadata: {
uid: "test2",
name: "test-link",
source: "test",
labels: {},
},
spec: {
url: "https://k8slens.dev",
},
status: {
phase: "available",
},
});
const entitykc = new KubernetesCluster({
metadata: {
uid: "test3",
name: "test-link",
source: "test",
labels: {},
},
spec: {
kubeconfigPath: "",
kubeconfigContext: "",
},
status: {
phase: "connected",
},
});
describe("CatalogEntityRegistry", () => {
let entityRegistry: CatalogEntityRegistry;
let catalogCategoryRegistry: CatalogCategoryRegistry;
beforeEach(() => {
const di = getDiForUnitTesting({ doGeneralOverrides: true });
entityRegistry = di.inject(catalogEntityRegistryInjectable);
catalogCategoryRegistry = di.inject(catalogCategoryRegistryInjectable);
});
describe("updateItems", () => {
it("adds new catalog item", () => {
const items = [{
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "KubernetesCluster",
metadata: {
uid: "123",
name: "foobar",
source: "test",
labels: {},
},
status: {
phase: "disconnected",
},
spec: {},
}];
entityRegistry.updateItems(items);
expect(entityRegistry.items.get().length).toEqual(1);
items.push({
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "KubernetesCluster",
metadata: {
uid: "456",
name: "barbaz",
source: "test",
labels: {},
},
status: {
phase: "disconnected",
},
spec: {},
});
entityRegistry.updateItems(items);
expect(entityRegistry.items.get().length).toEqual(2);
});
it("updates existing items", () => {
const items = [{
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "KubernetesCluster",
metadata: {
uid: "123",
name: "foobar",
source: "test",
labels: {},
},
status: {
phase: "disconnected",
},
spec: {},
}];
entityRegistry.updateItems(items);
expect(entityRegistry.items.get().length).toEqual(1);
expect(entityRegistry.items.get()[0].status.phase).toEqual("disconnected");
items[0].status.phase = "connected";
entityRegistry.updateItems(items);
expect(entityRegistry.items.get().length).toEqual(1);
expect(entityRegistry.items.get()[0].status.phase).toEqual("connected");
});
it("updates activeEntity", () => {
const items = [{
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "KubernetesCluster",
metadata: {
uid: "123",
name: "foobar",
source: "test",
labels: {},
},
status: {
phase: "disconnected",
},
spec: {},
}];
entityRegistry.updateItems(items);
entityRegistry.activeEntity = entityRegistry.items.get()[0];
expect(entityRegistry.activeEntity.status.phase).toEqual("disconnected");
items[0].status.phase = "connected";
entityRegistry.updateItems(items);
expect(entityRegistry.activeEntity.status.phase).toEqual("connected");
});
it("removes deleted items", () => {
const items = [
{
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "KubernetesCluster",
metadata: {
uid: "123",
name: "foobar",
source: "test",
labels: {},
},
status: {
phase: "disconnected",
},
spec: {},
},
{
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "KubernetesCluster",
metadata: {
uid: "456",
name: "barbaz",
source: "test",
labels: {},
},
status: {
phase: "disconnected",
},
spec: {},
},
];
entityRegistry.updateItems(items);
items.splice(0, 1);
entityRegistry.updateItems(items);
expect(entityRegistry.items.get().length).toEqual(1);
expect(entityRegistry.items.get()[0].metadata.uid).toEqual("456");
});
});
describe("items", () => {
it("does not return items without matching category", () => {
const items = [
{
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "KubernetesCluster",
metadata: {
uid: "123",
name: "foobar",
source: "test",
labels: {},
},
status: {
phase: "disconnected",
},
spec: {},
},
{
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "FooBar",
metadata: {
uid: "456",
name: "barbaz",
source: "test",
labels: {},
},
status: {
phase: "disconnected",
},
spec: {},
},
];
entityRegistry.updateItems(items);
expect(entityRegistry.items.get().length).toBe(1);
});
});
it("does return items after matching category is added", () => {
const items = [
{
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "FooBar",
metadata: {
uid: "456",
name: "barbaz",
source: "test",
labels: {},
},
status: {
phase: "disconnected",
},
spec: {},
},
];
entityRegistry.updateItems(items);
catalogCategoryRegistry.add(new FooBarCategory());
expect(entityRegistry.items.get().length).toBe(1);
});
it("does not return items that are filtered out", () => {
const source = observable.array([entity, entity2, entitykc]);
entityRegistry.updateItems(source);
expect(entityRegistry.items.get().length).toBe(3);
expect(entityRegistry.filteredItems.length).toBe(3);
const d = entityRegistry.addCatalogFilter(entity => entity.kind === KubernetesCluster.kind);
expect(entityRegistry.items.get().length).toBe(3);
expect(entityRegistry.filteredItems.length).toBe(1);
// Remove filter
d();
expect(entityRegistry.items.get().length).toBe(3);
expect(entityRegistry.filteredItems.length).toBe(3);
});
});
@@ -0,0 +1,43 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import defaultWebsocketApiParamsInjectable from "../default-websocket-api-params.injectable";
import type { WebSocketEvents } from "../websocket-api";
import { WebSocketApi } from "../websocket-api";
class TestWebSocketApi extends WebSocketApi<WebSocketEvents> {
flush(): void {
super.flush();
}
}
describe("WebsocketApi tests", () => {
let api: TestWebSocketApi;
beforeEach(() => {
const di = getDiForUnitTesting({ doGeneralOverrides: true });
api = new TestWebSocketApi({
defaultParams: di.inject(defaultWebsocketApiParamsInjectable),
}, {});
});
describe("before connection", () => {
it("does not hang when flush is called", () => {
api.flush();
});
describe("when a message has been sent", () => {
beforeEach(() => {
api.send("a command");
});
it("does not hang when flush is called", () => {
api.flush();
});
});
});
});
@@ -0,0 +1,15 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export { CatalogCategory, CatalogEntity } from "../../common/catalog";
export type {
CatalogEntityData,
CatalogEntityKindData,
CatalogEntityActionContext,
CatalogEntityAddMenuContext,
CatalogEntityAddMenu,
CatalogEntityContextMenu,
CatalogEntityContextMenuContext,
} from "../../common/catalog";
@@ -0,0 +1,18 @@
/**
* 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 { computed } from "mobx";
import catalogEntityRegistryInjectable from "./registry.injectable";
const activeEntityInjectable = getInjectable({
id: "active-entity",
instantiate: (di) => {
const registry = di.inject(catalogEntityRegistryInjectable);
return computed(() => registry.activeEntity);
},
});
export default activeEntityInjectable;
@@ -0,0 +1,13 @@
/**
* 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 catalogEntityRegistryInjectable from "./registry.injectable";
const catalogEnitiesInjectable = getInjectable({
id: "catalog-enities",
instantiate: (di) => di.inject(catalogEntityRegistryInjectable).items,
});
export default catalogEnitiesInjectable;
@@ -0,0 +1,22 @@
/**
* 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 clusterStoreInjectable from "../../../../common/cluster-store/cluster-store.injectable";
import type { Cluster } from "../../../../common/cluster/cluster";
import catalogEntityRegistryInjectable from "./registry.injectable";
export type GetActiveClusterEntity = () => Cluster | undefined;
const getActiveClusterEntityInjectable = getInjectable({
id: "get-active-cluster-entity",
instantiate: (di): GetActiveClusterEntity => {
const store = di.inject(clusterStoreInjectable);
const entityRegistry = di.inject(catalogEntityRegistryInjectable);
return () => store.getById(entityRegistry.activeEntity?.getId());
},
});
export default getActiveClusterEntityInjectable;
@@ -0,0 +1,32 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { computed } from "mobx";
import type { ClusterMetricsResourceType } from "../../../../common/cluster-types";
import currentKubeObjectInDetailsInjectable from "../../../components/kube-object-details/current-kube-object-in-details.injectable";
import enabledMetricsInjectable from "./metrics-enabled.injectable";
const metricsDetailsComponentEnabledInjectable = getInjectable({
id: "metrics-details-component-enabled",
instantiate: (di, kind) => {
const metricsEnabled = di.inject(enabledMetricsInjectable, kind);
const currentKubeObjectInDetails = di.inject(currentKubeObjectInDetailsInjectable);
return computed(() => {
const current = currentKubeObjectInDetails.value.get();
if (!current?.object) {
return false;
}
return current.object.kind == kind && metricsEnabled.get();
});
},
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, kind: ClusterMetricsResourceType) => kind,
}),
});
export default metricsDetailsComponentEnabledInjectable;
@@ -0,0 +1,22 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { computed } from "mobx";
import type { ClusterMetricsResourceType } from "../../../../common/cluster-types";
import getActiveClusterEntityInjectable from "./get-active-cluster-entity.injectable";
const enabledMetricsInjectable = getInjectable({
id: "enabled-metrics",
instantiate: (di, kind) => {
const getActiveClusterEntity = di.inject(getActiveClusterEntityInjectable);
return computed(() => !getActiveClusterEntity()?.isMetricHidden(kind));
},
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, kind: ClusterMetricsResourceType) => kind,
}),
});
export default enabledMetricsInjectable;
@@ -0,0 +1,20 @@
/**
* 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 catalogCategoryRegistryInjectable from "../../../../common/catalog/category-registry.injectable";
import navigateInjectable from "../../../navigation/navigate.injectable";
import { CatalogEntityRegistry } from "./registry";
import loggerInjectable from "../../../../common/logger.injectable";
const catalogEntityRegistryInjectable = getInjectable({
id: "catalog-entity-registry",
instantiate: (di) => new CatalogEntityRegistry({
categoryRegistry: di.inject(catalogCategoryRegistryInjectable),
navigate: di.inject(navigateInjectable),
logger: di.inject(loggerInjectable),
}),
});
export default catalogEntityRegistryInjectable;
@@ -0,0 +1,262 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { computed, observable, makeObservable, action } from "mobx";
import { ipcRendererOn } from "../../../../common/ipc";
import type { CatalogCategory, CatalogEntity, CatalogEntityData, CatalogCategoryRegistry, CatalogEntityKindData } from "../../../../common/catalog";
import "../../../../common/catalog-entities";
import { iter } from "../../../utils";
import type { Disposer } from "../../../utils";
import { once } from "lodash";
import { CatalogRunEvent } from "../../../../common/catalog/catalog-run-event";
import { ipcRenderer } from "electron";
import { catalogInitChannel, catalogItemsChannel, catalogEntityRunListener } from "../../../../common/ipc/catalog";
import { isMainFrame } from "process";
import type { Navigate } from "../../../navigation/navigate.injectable";
import type { Logger } from "../../../../common/logger";
export type EntityFilter = (entity: CatalogEntity) => any;
export type CatalogEntityOnBeforeRun = (event: CatalogRunEvent) => void | Promise<void>;
interface Dependencies {
navigate: Navigate;
readonly categoryRegistry: CatalogCategoryRegistry;
logger: Logger;
}
export class CatalogEntityRegistry {
protected readonly activeEntityId = observable.box<string | undefined>(undefined);
protected readonly _entities = observable.map<string, CatalogEntity>([], { deep: true });
protected readonly filters = observable.set<EntityFilter>([], {
deep: false,
});
protected readonly onBeforeRunHooks = observable.set<CatalogEntityOnBeforeRun>([], {
deep: false,
});
/**
* Buffer for keeping entities that don't yet have CatalogCategory synced
*/
protected readonly rawEntities: (CatalogEntityData & CatalogEntityKindData)[] = [];
constructor(protected readonly dependencies: Dependencies) {
makeObservable(this);
}
protected getActiveEntityById() {
const activeEntityId = this.activeEntityId.get();
if (!activeEntityId) {
return undefined;
}
return this._entities.get(activeEntityId);
}
get activeEntity(): CatalogEntity | undefined {
const entity = this.getActiveEntityById();
// If the entity was not found but there are rawEntities to be processed,
// try to process them and return the entity.
// This might happen if an extension registered a new Catalog category.
if (this.activeEntityId && !entity && this.rawEntities.length > 0) {
this.processRawEntities();
return this.getActiveEntityById();
}
return entity;
}
set activeEntity(raw: CatalogEntity | string | undefined) {
if (raw) {
const id = typeof raw === "string"
? raw
: raw.getId();
this.activeEntityId.set(id);
} else {
this.activeEntityId.set(undefined);
}
}
init() {
ipcRendererOn(catalogItemsChannel, (event, items: (CatalogEntityData & CatalogEntityKindData)[]) => {
this.updateItems(items);
});
// Make sure that we get items ASAP and not the next time one of them changes
ipcRenderer.send(catalogInitChannel);
if (isMainFrame) {
ipcRendererOn(catalogEntityRunListener, (event, id: string) => {
const entity = this.getById(id);
if (entity) {
this.onRun(entity);
}
});
}
}
@action updateItems(items: (CatalogEntityData & CatalogEntityKindData)[]) {
this.rawEntities.length = 0;
const newIds = new Set(items.map((item) => item.metadata.uid));
for (const uid of this._entities.keys()) {
if (!newIds.has(uid)) {
this._entities.delete(uid);
}
}
for (const item of items) {
this.updateItem(item);
}
}
@action protected updateItem(item: (CatalogEntityData & CatalogEntityKindData)) {
const existing = this._entities.get(item.metadata.uid);
if (!existing) {
const entity = this.dependencies.categoryRegistry.getEntityForData(item);
if (entity) {
this._entities.set(entity.getId(), entity);
} else {
this.rawEntities.push(item);
}
} else {
existing.metadata = item.metadata;
existing.spec = item.spec;
existing.status = item.status;
}
}
protected processRawEntities() {
const items = [...this.rawEntities];
this.rawEntities.length = 0;
for (const item of items) {
this.updateItem(item);
}
}
readonly items = computed(() => {
this.processRawEntities();
return Array.from(this._entities.values());
});
@computed get filteredItems() {
return Array.from(
iter.reduce(
this.filters,
iter.filter,
this.items.get().values(),
),
);
}
@computed get entities(): Map<string, CatalogEntity> {
return new Map(
this.items.get().map(entity => [entity.getId(), entity]),
);
}
@computed get filteredEntities(): Map<string, CatalogEntity> {
return new Map(
this.filteredItems.map(entity => [entity.getId(), entity]),
);
}
getById(id: string) {
return this.entities.get(id);
}
getItemsForApiKind<T extends CatalogEntity>(apiVersion: string, kind: string, { filtered = false } = {}): T[] {
const byApiKind = (item: CatalogEntity) => item.apiVersion === apiVersion && item.kind === kind;
const entities = filtered ? this.filteredItems : this.items.get();
return entities.filter(byApiKind) as T[];
}
getItemsForCategory<T extends CatalogEntity>(category: CatalogCategory, { filtered = false } = {}): T[] {
const supportedVersions = new Set(category.spec.versions.map((v) => `${category.spec.group}/${v.name}`));
const byApiVersionKind = (item: CatalogEntity) => supportedVersions.has(item.apiVersion) && item.kind === category.spec.names.kind;
const entities = filtered ? this.filteredItems : this.items.get();
return entities.filter(byApiVersionKind) as T[];
}
/**
* Add a new filter to the set of item filters
* @param fn The function that should return a truthy value if that entity should be sent currently "active"
* @returns A function to remove that filter
*/
@action addCatalogFilter(fn: EntityFilter): Disposer {
this.filters.add(fn);
return once(() => void this.filters.delete(fn));
}
/**
* Add a onBeforeRun hook. If `onBeforeRun` was previously added then it will not be added again
* @param onBeforeRun The function that should return a boolean if the onRun of catalog entity should be triggered.
* @returns A function to remove that hook
*/
@action addOnBeforeRun(onBeforeRun: CatalogEntityOnBeforeRun): Disposer {
this.onBeforeRunHooks.add(onBeforeRun);
return once(() => void this.onBeforeRunHooks.delete(onBeforeRun));
}
/**
* Runs all the registered `onBeforeRun` hooks, short circuiting on the first event that's preventDefaulted
* @param entity The entity to run the hooks on
* @returns Whether the entities `onRun` method should be executed
*/
async onBeforeRun(entity: CatalogEntity): Promise<boolean> {
this.dependencies.logger.debug(`[CATALOG-ENTITY-REGISTRY]: run onBeforeRun on ${entity.getId()}`);
const runEvent = new CatalogRunEvent({ target: entity });
for (const onBeforeRun of this.onBeforeRunHooks) {
try {
await onBeforeRun(runEvent);
} catch (error) {
this.dependencies.logger.warn(`[CATALOG-ENTITY-REGISTRY]: entity ${entity.getId()} onBeforeRun threw an error`, error);
}
if (runEvent.defaultPrevented) {
return false;
}
}
return true;
}
/**
* Perform the onBeforeRun check and, if successful, then proceed to call `entity`'s onRun method
* @param entity The instance to invoke the hooks and then execute the onRun
*/
onRun(entity: CatalogEntity): void {
this.onBeforeRun(entity)
.then(doOnRun => {
if (doOnRun) {
return entity.onRun?.({
navigate: this.dependencies.navigate,
setCommandPaletteContext: (entity) => {
this.activeEntity = entity;
},
});
} else {
this.dependencies.logger.debug(`onBeforeRun for ${entity.getId()} returned false`);
}
})
.catch(error => this.dependencies.logger.error(`[CATALOG-ENTITY-REGISTRY]: entity ${entity.getId()} onRun threw an error`, error));
}
}
@@ -0,0 +1,35 @@
/**
* 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 assert from "assert";
import loggerInjectable from "../../common/logger.injectable";
import hostedClusterIdInjectable from "../cluster-frame-context/hosted-cluster-id.injectable";
import defaultWebsocketApiParamsInjectable from "./default-websocket-api-params.injectable";
import type { TerminalApiDependencies, TerminalApiQuery } from "./terminal-api";
import { TerminalApi } from "./terminal-api";
export type CreateTerminalApi = (query: TerminalApiQuery) => TerminalApi;
const createTerminalApiInjectable = getInjectable({
id: "create-terminal-api",
instantiate: (di): CreateTerminalApi => {
const hostedClusterId = di.inject(hostedClusterIdInjectable);
const deps: Omit<TerminalApiDependencies, "hostedClusterId"> = {
logger: di.inject(loggerInjectable),
defaultParams: di.inject(defaultWebsocketApiParamsInjectable),
};
return (query) => {
assert(hostedClusterId, "Can only create terminal APIs within a cluster frame");
return new TerminalApi({
hostedClusterId,
...deps,
}, query);
};
},
});
export default createTerminalApiInjectable;
@@ -0,0 +1,22 @@
/**
* 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 { TerminalMessage } from "../../common/terminal/channels";
import { TerminalChannels } from "../../common/terminal/channels";
import isDevelopmentInjectable from "../../common/vars/is-development.injectable";
export type DefaultWebsocketApiParams = ReturnType<typeof defaultWebsocketApiParamsInjectable.instantiate>;
const defaultWebsocketApiParamsInjectable = getInjectable({
id: "default-websocket-api-params",
instantiate: (di) => ({
logging: di.inject(isDevelopmentInjectable),
reconnectDelay: 10,
flushOnOpen: true,
pingMessage: JSON.stringify({ type: TerminalChannels.PING } as TerminalMessage),
}),
});
export default defaultWebsocketApiParamsInjectable;
@@ -0,0 +1,53 @@
/**
* 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 { reaction, when } from "mobx";
import type { GeneralEntity } from "../../../common/catalog-entities";
import generalCategoryInjectable from "../../../common/catalog/categories/general.injectable";
import isActiveRouteInjectable from "../../navigation/is-route-active.injectable";
import observableHistoryInjectable from "../../navigation/observable-history.injectable";
import type { Disposer } from "../../utils";
import { disposer } from "../../utils";
import catalogEntityRegistryInjectable from "../catalog/entity/registry.injectable";
export type WatchForGeneralEntityNavigation = () => Disposer;
const watchForGeneralEntityNavigationInjectable = getInjectable({
id: "watch-for-general-entity-navigation",
instantiate: (di): WatchForGeneralEntityNavigation => {
const observableHistory = di.inject(observableHistoryInjectable);
const isActiveRoute = di.inject(isActiveRouteInjectable);
const entityRegistry = di.inject(catalogEntityRegistryInjectable);
const generalCategory = di.inject(generalCategoryInjectable);
return () => {
const dispose = disposer();
dispose.push(when(
() => entityRegistry.entities.size > 0,
() => {
dispose.push(reaction(
() => observableHistory.location,
() => {
const entities = entityRegistry.getItemsForCategory(generalCategory) as GeneralEntity[];
const activeEntity = entities.find(entity => isActiveRoute(entity.spec.path));
if (activeEntity) {
entityRegistry.activeEntity = activeEntity;
}
},
{
fireImmediately: true,
},
));
},
));
return dispose;
};
},
});
export default watchForGeneralEntityNavigationInjectable;
@@ -0,0 +1,185 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { WebSocketApiDependencies, WebSocketEvents } from "./websocket-api";
import { WebSocketApi } from "./websocket-api";
import isEqual from "lodash/isEqual";
import url from "url";
import { makeObservable, observable } from "mobx";
import { ipcRenderer } from "electron";
import type { Logger } from "../../common/logger";
import { once } from "lodash";
import { type TerminalMessage, TerminalChannels } from "../../common/terminal/channels";
enum TerminalColor {
RED = "\u001b[31m",
GREEN = "\u001b[32m",
YELLOW = "\u001b[33m",
BLUE = "\u001b[34m",
MAGENTA = "\u001b[35m",
CYAN = "\u001b[36m",
GRAY = "\u001b[90m",
LIGHT_GRAY = "\u001b[37m",
NO_COLOR = "\u001b[0m",
}
export interface TerminalApiQuery extends Record<string, string | undefined> {
id: string;
node?: string;
type?: string;
}
export interface TerminalEvents extends WebSocketEvents {
ready: () => void;
connected: () => void;
}
export interface TerminalApiDependencies extends WebSocketApiDependencies {
readonly hostedClusterId: string;
readonly logger: Logger;
}
export class TerminalApi extends WebSocketApi<TerminalEvents> {
protected size?: { width: number; height: number };
@observable public isReady = false;
constructor(protected readonly dependencies: TerminalApiDependencies, protected readonly query: TerminalApiQuery) {
super(dependencies, {
flushOnOpen: false,
pingInterval: 30,
});
makeObservable(this);
if (query.node) {
query.type ||= "node";
}
}
async connect() {
if (!this.socket) {
/**
* Only emit this message if we are not "reconnecting", so as to keep the
* output display clean when the computer wakes from sleep
*/
this.emitStatus("Connecting ...");
}
const authTokenArray = await ipcRenderer.invoke("cluster:shell-api", this.dependencies.hostedClusterId, this.query.id);
if (!(authTokenArray instanceof Uint8Array)) {
throw new TypeError("ShellApi token is not a Uint8Array");
}
const { hostname, protocol, port } = location;
const socketUrl = url.format({
protocol: protocol.includes("https") ? "wss" : "ws",
hostname,
port,
pathname: "/api",
query: {
...this.query,
shellToken: Buffer.from(authTokenArray).toString("base64"),
},
slashes: true,
});
const onReady = once((data?: string) => {
this.isReady = true;
this.emit("ready");
this.removeListener("data", onReady);
this.removeListener("connected", onReady);
this.flush();
// data is undefined if the event that was handled is "connected"
if (data === undefined) {
const lastData = window.localStorage.getItem(`${this.query.id}:last-data`);
if (lastData) {
/**
* Output the last line, the makes sure that the terminal isn't completely
* empty when the user refreshes.
*/
this.emit("data", lastData);
}
}
});
this.prependListener("data", onReady);
this.prependListener("connected", onReady);
super.connect(socketUrl);
}
sendMessage(message: TerminalMessage) {
return this.send(JSON.stringify(message));
}
sendTerminalSize(cols: number, rows: number) {
const newSize = { width: cols, height: rows };
if (!isEqual(this.size, newSize)) {
this.sendMessage({
type: TerminalChannels.RESIZE,
data: newSize,
});
this.size = newSize;
}
}
protected _onMessage({ data, ...evt }: MessageEvent<string>): void {
try {
const message = JSON.parse(data) as TerminalMessage;
switch (message.type) {
case TerminalChannels.STDOUT:
/**
* save the last data for reconnections. User localStorage because we
* don't want this data to survive if the app is closed
*/
window.localStorage.setItem(`${this.query.id}:last-data`, message.data);
super._onMessage({ data: message.data, ...evt });
break;
case TerminalChannels.CONNECTED:
this.emit("connected");
break;
default:
this.dependencies.logger.warn(`[TERMINAL-API]: unknown or unhandleable message type`, message);
break;
}
} catch (error) {
this.dependencies.logger.error(`[TERMINAL-API]: failed to handle message`, error);
}
}
protected _onOpen(evt: Event) {
// Client should send terminal size in special channel 4,
// But this size will be changed by terminal.fit()
this.sendTerminalSize(120, 80);
super._onOpen(evt);
}
protected _onClose(evt: CloseEvent) {
super._onClose(evt);
this.isReady = false;
}
protected emitStatus(data: string, options: { color?: TerminalColor; showTime?: boolean } = {}) {
const { color, showTime } = options;
const time = showTime ? `${(new Date()).toLocaleString()} ` : "";
if (color) {
data = `${color}${data}${TerminalColor.NO_COLOR}`;
}
this.emit("data", `${time}${data}\r\n`);
}
protected emitError(error: string) {
this.emitStatus(error, {
color: TerminalColor.RED,
});
}
}
@@ -0,0 +1,201 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { observable, makeObservable } from "mobx";
import EventEmitter from "events";
import type TypedEventEmitter from "typed-emitter";
import type { Arguments } from "typed-emitter";
import type { Defaulted } from "../utils";
import type { DefaultWebsocketApiParams } from "./default-websocket-api-params.injectable";
interface WebsocketApiParams {
/**
* Flush pending commands on open socket
*
* @default true
*/
flushOnOpen?: boolean;
/**
* In case of an error, wait this many seconds before reconnecting.
*
* If falsy, don't reconnect
*
* @default 10
*/
reconnectDelay?: number;
/**
* The message for pinging the websocket
*
* @default "{type: \"ping\"}"
*/
pingMessage?: string;
/**
* If set to a number > 0, then the API will ping the socket on that interval.
*
* @unit seconds
*/
pingInterval?: number;
/**
* Whether to show logs in the console
*
* @default isDevelopment
*/
logging?: boolean;
}
export enum WebSocketApiState {
PENDING = "pending",
OPEN = "open",
CONNECTING = "connecting",
RECONNECTING = "reconnecting",
CLOSED = "closed",
}
export interface WebSocketEvents {
open: () => void;
data: (message: string) => void;
close: () => void;
}
export interface WebSocketApiDependencies {
readonly defaultParams: DefaultWebsocketApiParams;
}
export class WebSocketApi<Events extends WebSocketEvents> extends (EventEmitter as { new<T>(): TypedEventEmitter<T> })<Events> {
protected socket: WebSocket | null = null;
protected pendingCommands: string[] = [];
protected reconnectTimer?: number;
protected pingTimer?: number;
protected params: Defaulted<WebsocketApiParams, keyof DefaultWebsocketApiParams>;
@observable readyState = WebSocketApiState.PENDING;
constructor(protected readonly dependencies: WebSocketApiDependencies, params: WebsocketApiParams) {
super();
makeObservable(this);
this.params = {
...this.dependencies.defaultParams,
...params,
};
const { pingInterval } = this.params;
if (pingInterval) {
this.pingTimer = window.setInterval(() => this.ping(), pingInterval * 1000);
}
}
isConnected(): this is (WebSocketApi<Events> & { socket: WebSocket }) {
return this.socket?.readyState === WebSocket.OPEN;
}
connect(url: string) {
// close previous connection first
this.socket?.close();
// start new connection
this.socket = new WebSocket(url);
this.socket.addEventListener("open", ev => this._onOpen(ev));
this.socket.addEventListener("message", ev => this._onMessage(ev));
this.socket.addEventListener("error", ev => this._onError(ev));
this.socket.addEventListener("close", ev => this._onClose(ev));
this.readyState = WebSocketApiState.CONNECTING;
}
ping() {
if (this.isConnected()) {
this.send(this.params.pingMessage);
}
}
reconnect(): void {
if (!this.socket) {
return void console.error("[WEBSOCKET-API]: cannot reconnect to a socket that is not connected");
}
this.connect(this.socket.url);
}
destroy() {
if (!this.socket) return;
this.socket.close();
this.socket = null;
this.pendingCommands = [];
this.clearAllListeners();
clearTimeout(this.reconnectTimer);
clearInterval(this.pingTimer);
this.readyState = WebSocketApiState.PENDING;
}
clearAllListeners() {
for (const name of this.eventNames()) {
this.removeAllListeners(name as keyof Events);
}
}
send(command: string) {
if (this.isConnected()) {
this.socket.send(command);
} else {
this.pendingCommands.push(command);
}
}
protected flush() {
const commands = this.pendingCommands;
this.pendingCommands = [];
for (const command of commands) {
this.send(command);
}
}
protected _onOpen(evt: Event) {
this.emit("open", ...[] as Arguments<Events["open"]>);
if (this.params.flushOnOpen) this.flush();
this.readyState = WebSocketApiState.OPEN;
this.writeLog("%cOPEN", "color:green;font-weight:bold;", evt);
}
protected _onMessage({ data }: MessageEvent): void {
this.emit("data", ...[data] as Arguments<Events["data"]>);
this.writeLog("%cMESSAGE", "color:black;font-weight:bold;", data);
}
protected _onError(evt: Event) {
this.writeLog("%cERROR", "color:red;font-weight:bold;", evt);
}
protected _onClose(evt: CloseEvent) {
const error = evt.code !== 1000 || !evt.wasClean;
if (error) {
const { reconnectDelay } = this.params;
if (reconnectDelay && this.socket) {
const url = this.socket.url;
this.writeLog("will reconnect in", `${reconnectDelay}s`);
this.reconnectTimer = window.setTimeout(() => this.connect(url), reconnectDelay * 1000);
this.readyState = WebSocketApiState.RECONNECTING;
}
} else {
this.readyState = WebSocketApiState.CLOSED;
this.emit("close", ...[] as Arguments<Events["close"]>);
}
this.writeLog("%cCLOSE", `color:${error ? "red" : "black"};font-weight:bold;`, evt);
}
protected writeLog(...data: any[]) {
if (this.params.logging) {
console.debug(...data);
}
}
}
@@ -0,0 +1,28 @@
/**
* 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 appPathsStateInjectable from "../../common/app-paths/app-paths-state.injectable";
import { beforeFrameStartsFirstInjectionToken } from "../before-frame-starts/tokens";
import { appPathsChannel } from "../../common/app-paths/app-paths-channel";
import { requestFromChannelInjectionToken } from "../../common/utils/channel/request-from-channel-injection-token";
const setupAppPathsInjectable = getInjectable({
id: "setup-app-paths",
instantiate: (di) => ({
id: "setup-app-paths",
run: async () => {
const requestFromChannel = di.inject(requestFromChannelInjectionToken);
const appPathsState = di.inject(appPathsStateInjectable);
const appPaths = await requestFromChannel(appPathsChannel);
appPathsState.set(appPaths);
},
}),
injectionToken: beforeFrameStartsFirstInjectionToken,
});
export default setupAppPathsInjectable;
@@ -0,0 +1,14 @@
/**
* 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 { shouldBaseStoreDisableSyncInIpcListenerInjectionToken } from "../../common/base-store/disable-sync";
const shouldBaseStoreDisableSyncInIpcListenerInjectable = getInjectable({
id: "should-base-store-disable-sync-in-ipc-listener",
instantiate: () => true,
injectionToken: shouldBaseStoreDisableSyncInIpcListenerInjectionToken,
});
export default shouldBaseStoreDisableSyncInIpcListenerInjectable;
@@ -0,0 +1,17 @@
/**
* 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 { baseStoreIpcChannelPrefixesInjectionToken } from "../../common/base-store/channel-prefix";
const baseStoreIpcChannelPrefixInjectable = getInjectable({
id: "base-store-ipc-channel-prefix",
instantiate: () => ({
local: "store-sync-renderer",
remote: "store-sync-main",
}),
injectionToken: baseStoreIpcChannelPrefixesInjectionToken,
});
export default baseStoreIpcChannelPrefixInjectable;
@@ -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 { persistStateToConfigInjectionToken } from "../../common/base-store/save-to-file";
import { noop } from "../utils";
const persistStateToConfigInjectable = getInjectable({
id: "persist-state-to-config",
instantiate: () => noop,
injectionToken: persistStateToConfigInjectionToken,
});
export default persistStateToConfigInjectable;
@@ -0,0 +1,23 @@
/**
* 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 { setAutoFreeze, enableMapSet } from "immer";
import { beforeFrameStartsFirstInjectionToken } from "../tokens";
const configureImmerInjectable = getInjectable({
id: "configure-immer",
instantiate: () => ({
id: "configure-immer",
run: () => {
// Docs: https://immerjs.github.io/immer/
// Required in `utils/storage-helper.ts`
setAutoFreeze(false); // allow to merge mobx observables
enableMapSet(); // allow to merge maps and sets
},
}),
injectionToken: beforeFrameStartsFirstInjectionToken,
});
export default configureImmerInjectable;
@@ -0,0 +1,28 @@
/**
* 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 { configure } from "mobx";
import { beforeFrameStartsFirstInjectionToken } from "../tokens";
const configureMobxInjectable = getInjectable({
id: "configure-mobx",
instantiate: () => ({
id: "configure-mobx",
run: () => {
// Docs: https://mobx.js.org/configuration.html
configure({
enforceActions: "never",
// TODO: enable later (read more: https://mobx.js.org/migrating-from-4-or-5.html)
// computedRequiresReaction: true,
// reactionRequiresObservable: true,
// observableRequiresReaction: true,
});
},
}),
injectionToken: beforeFrameStartsFirstInjectionToken,
});
export default configureMobxInjectable;
@@ -0,0 +1,24 @@
/**
* 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 { customMonacoThemeInjectionToken } from "../../components/monaco-editor";
import addNewMonacoThemeInjectable from "../../monaco/add-new-theme.injectable";
import { beforeFrameStartsSecondInjectionToken } from "../tokens";
const loadMonacoThemesInjectable = getInjectable({
id: "load-monaco-themes",
instantiate: (di) => ({
id: "load-monaco-themes",
run: () => {
const customThemes = di.injectMany(customMonacoThemeInjectionToken);
const addNewMonacoTheme = di.inject(addNewMonacoThemeInjectable);
customThemes.forEach(addNewMonacoTheme);
},
}),
injectionToken: beforeFrameStartsSecondInjectionToken,
});
export default loadMonacoThemesInjectable;
@@ -0,0 +1,93 @@
/**
* 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 autoRegistrationEmitterInjectable from "../../../common/k8s-api/api-manager/auto-registration-emitter.injectable";
import apiManagerInjectable from "../../../common/k8s-api/api-manager/manager.injectable";
import { CustomResourceStore } from "../../../common/k8s-api/api-manager/resource.store";
import type { CustomResourceDefinition } from "../../../common/k8s-api/endpoints";
import type { KubeApiDependencies } from "../../../common/k8s-api/kube-api";
import { KubeApi } from "../../../common/k8s-api/kube-api";
import { KubeObject } from "../../../common/k8s-api/kube-object";
import { beforeClusterFrameStartsSecondInjectionToken } from "../tokens";
import type { KubeObjectStoreDependencies } from "../../../common/k8s-api/kube-object.store";
import clusterFrameContextForNamespacedResourcesInjectable from "../../cluster-frame-context/for-namespaced-resources.injectable";
import loggerInjectable from "../../../common/logger.injectable";
import maybeKubeApiInjectable from "../../../common/k8s-api/maybe-kube-api.injectable";
const setupAutoRegistrationInjectable = getInjectable({
id: "setup-auto-registration",
instantiate: (di) => ({
id: "setup-auto-registration",
run: () => {
const autoRegistrationEmitter = di.inject(autoRegistrationEmitterInjectable);
const beforeApiManagerInitializationCrds: CustomResourceDefinition[] = [];
const beforeApiManagerInitializationApis: KubeApi[] = [];
const kubeApiDependencies: KubeApiDependencies = {
logger: di.inject(loggerInjectable),
maybeKubeApi: di.inject(maybeKubeApiInjectable),
};
const kubeObjectStoreDependencies: KubeObjectStoreDependencies = {
context: di.inject(clusterFrameContextForNamespacedResourcesInjectable),
logger: di.inject(loggerInjectable),
};
let initialized = false;
const autoInitCustomResourceStore = (crd: CustomResourceDefinition) => {
const objectConstructor = class extends KubeObject {
static readonly kind = crd.getResourceKind();
static readonly namespaced = crd.isNamespaced();
static readonly apiBase = crd.getResourceApiBase();
};
const api = (() => {
const rawApi = apiManager.getApi(objectConstructor.apiBase);
if (rawApi) {
return rawApi;
}
const api = new KubeApi(kubeApiDependencies, { objectConstructor });
apiManager.registerApi(api);
return api;
})();
if (!apiManager.getStore(api)) {
apiManager.registerStore(new CustomResourceStore(kubeObjectStoreDependencies, api));
}
};
const autoInitKubeApi = (api: KubeApi) => {
apiManager.registerApi(api);
};
autoRegistrationEmitter
.on("customResourceDefinition", (crd) => {
if (initialized) {
autoInitCustomResourceStore(crd);
} else {
beforeApiManagerInitializationCrds.push(crd);
}
})
.on("kubeApi", (api) => {
if (initialized) {
autoInitKubeApi(api);
} else {
beforeApiManagerInitializationApis.push(api);
}
});
// NOTE: this MUST happen after the event emitter listeners are registered
const apiManager = di.inject(apiManagerInjectable);
beforeApiManagerInitializationCrds.forEach(autoInitCustomResourceStore);
beforeApiManagerInitializationApis.forEach(autoInitKubeApi);
initialized = true;
},
}),
injectionToken: beforeClusterFrameStartsSecondInjectionToken,
});
export default setupAutoRegistrationInjectable;
@@ -0,0 +1,32 @@
/**
* 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 { reaction } from "mobx";
import { currentClusterMessageChannel } from "../../../common/cluster/current-cluster-channel";
import { sendMessageToChannelInjectionToken } from "../../../common/utils/channel/message-to-channel-injection-token";
import matchedClusterIdInjectable from "../../navigation/matched-cluster-id.injectable";
import { beforeMainFrameStartsFirstInjectionToken } from "../tokens";
const setupCurrentClusterBroadcastInjectable = getInjectable({
id: "setup-current-cluster-broadcast",
instantiate: (di) => ({
id: "setup-current-cluster-broadcast",
run: () => {
const matchedClusterId = di.inject(matchedClusterIdInjectable);
const sendMessageToChannel = di.inject(sendMessageToChannelInjectionToken);
reaction(
() => matchedClusterId.get(),
clusterId => sendMessageToChannel(currentClusterMessageChannel, clusterId),
{
fireImmediately: true,
},
);
},
}),
injectionToken: beforeMainFrameStartsFirstInjectionToken,
});
export default setupCurrentClusterBroadcastInjectable;
@@ -0,0 +1,80 @@
/**
* 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 kubernetesClusterCategoryInjectable from "../../../common/catalog/categories/kubernetes-cluster.injectable";
import navigateToAddClusterInjectable from "../../../common/front-end-routing/routes/add-cluster/navigate-to-add-cluster.injectable";
import isLinuxInjectable from "../../../common/vars/is-linux.injectable";
import isWindowsInjectable from "../../../common/vars/is-windows.injectable";
import openPathPickingDialogInjectable from "../../../features/path-picking-dialog/renderer/pick-paths.injectable";
import addSyncEntriesInjectable from "../../initializers/add-sync-entries.injectable";
import { beforeFrameStartsSecondInjectionToken } from "../tokens";
const setupKubernetesClusterCatalogAddMenuListenerInjectable = getInjectable({
id: "setup-kubernetes-cluster-catalog-add-menu-listener",
instantiate: (di) => ({
id: "setup-kubernetes-cluster-catalog-add-menu-listener",
run: () => {
const navigateToAddCluster = di.inject(navigateToAddClusterInjectable);
const addSyncEntries = di.inject(addSyncEntriesInjectable);
const kubernetesClusterCategory = di.inject(kubernetesClusterCategoryInjectable);
const isWindows = di.inject(isWindowsInjectable);
const isLinux = di.inject(isLinuxInjectable);
const openPathPickingDialog = di.inject(openPathPickingDialogInjectable);
kubernetesClusterCategory.on("catalogAddMenu", ctx => {
ctx.menuItems.push(
{
icon: "text_snippet",
title: "Add from kubeconfig",
onClick: navigateToAddCluster,
},
);
if (isWindows || isLinux) {
ctx.menuItems.push(
{
icon: "create_new_folder",
title: "Sync kubeconfig folder(s)",
defaultAction: true,
onClick: () => openPathPickingDialog({
message: "Sync folder(s)",
buttonLabel: "Sync",
properties: ["showHiddenFiles", "multiSelections", "openDirectory"],
onPick: addSyncEntries,
}),
},
{
icon: "note_add",
title: "Sync kubeconfig file(s)",
onClick: () => openPathPickingDialog({
message: "Sync file(s)",
buttonLabel: "Sync",
properties: ["showHiddenFiles", "multiSelections", "openFile"],
onPick: addSyncEntries,
}),
},
);
} else {
ctx.menuItems.push(
{
icon: "create_new_folder",
title: "Sync kubeconfig(s)",
defaultAction: true,
onClick: () => openPathPickingDialog({
message: "Sync file(s)",
buttonLabel: "Sync",
properties: ["showHiddenFiles", "multiSelections", "openFile", "openDirectory"],
onPick: addSyncEntries,
}),
},
);
}
});
},
}),
injectionToken: beforeFrameStartsSecondInjectionToken,
});
export default setupKubernetesClusterCatalogAddMenuListenerInjectable;
@@ -0,0 +1,56 @@
/**
* 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 catalogCategoryRegistryInjectable from "../../../common/catalog/category-registry.injectable";
import getClusterByIdInjectable from "../../../common/cluster-store/get-by-id.injectable";
import readFileInjectable from "../../../common/fs/read-file.injectable";
import { loadConfigFromString } from "../../../common/kube-helpers";
import loggerInjectable from "../../../common/logger.injectable";
import openDeleteClusterDialogInjectable from "../../components/delete-cluster-dialog/open.injectable";
import { beforeFrameStartsSecondInjectionToken } from "../tokens";
const setupKubernetesClusterContextMenuOpenInjectable = getInjectable({
id: "setup-kubernetes-cluster-context-menu-open",
instantiate: (di) => ({
id: "setup-kubernetes-cluster-context-menu-open",
run: () => {
const catalogCategoryRegistry = di.inject(catalogCategoryRegistryInjectable);
const openDeleteClusterDialog = di.inject(openDeleteClusterDialogInjectable);
const readFile = di.inject(readFileInjectable);
const getClusterById = di.inject(getClusterByIdInjectable);
const logger = di.inject(loggerInjectable);
catalogCategoryRegistry
.getForGroupKind("entity.k8slens.dev", "KubernetesCluster")
?.on("contextMenuOpen", (entity, context) => {
if (entity.metadata?.source == "local") {
context.menuItems.push({
title: "Remove",
icon: "delete",
onClick: async () => {
const clusterId = entity.getId();
const cluster = getClusterById(entity.getId());
if (!cluster) {
return logger.warn("[KUBERNETES-CLUSTER]: cannot delete cluster, does not exist in store", { clusterId });
}
const result = loadConfigFromString(await readFile(cluster.kubeConfigPath));
if (result.error) {
logger.error("[KUBERNETES-CLUSTER]: failed to parse kubeconfig file", result.error);
} else {
openDeleteClusterDialog(result.config, cluster);
}
},
});
}
});
},
}),
injectionToken: beforeFrameStartsSecondInjectionToken,
});
export default setupKubernetesClusterContextMenuOpenInjectable;
@@ -0,0 +1,24 @@
/**
* 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 { beforeFrameStartsFirstInjectionToken } from "../tokens";
import lensProxyCertificateInjectable from "../../../common/certificate/lens-proxy-certificate.injectable";
import requestLensProxyCertificateInjectable from "../../certificate/request-lens-proxy-certificate.injectable";
const setupLensProxyCertificateInjectable = getInjectable({
id: "setup-lens-proxy-certificate",
instantiate: (di) => ({
id: "setup-lens-proxy-certificate",
run: async () => {
const requestLensProxyCertificate = di.inject(requestLensProxyCertificateInjectable);
const lensProxyCertificate = di.inject(lensProxyCertificateInjectable);
lensProxyCertificate.set(await requestLensProxyCertificate());
},
}),
injectionToken: beforeFrameStartsFirstInjectionToken,
});
export default setupLensProxyCertificateInjectable;
@@ -0,0 +1,23 @@
/**
* 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 isMacInjectable from "../../../common/vars/is-mac.injectable";
import { beforeFrameStartsSecondInjectionToken } from "../tokens";
const setupRootMacClassnameInjectable = getInjectable({
id: "setup-root-mac-classname",
instantiate: (di) => ({
id: "setup-root-mac-classname",
run: () => {
const isMac = di.inject(isMacInjectable);
const rootElem = document.getElementById("app");
rootElem?.classList.toggle("is-mac", isMac);
},
}),
injectionToken: beforeFrameStartsSecondInjectionToken,
});
export default setupRootMacClassnameInjectable;
@@ -0,0 +1,23 @@
/**
* 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 initializeSentryReportingWithInjectable from "../../../common/error-reporting/initialize-sentry-reporting.injectable";
import { beforeMainFrameStartsFirstInjectionToken } from "../tokens";
import { init } from "@sentry/electron/renderer";
const setupSentryInjectable = getInjectable({
id: "setup-sentry",
instantiate: (di) => ({
id: "setup-sentry",
run: () => {
const initializeSentryReportingWith = di.inject(initializeSentryReportingWithInjectable);
initializeSentryReportingWith(init);
},
}),
injectionToken: beforeMainFrameStartsFirstInjectionToken,
});
export default setupSentryInjectable;
@@ -0,0 +1,34 @@
/**
* 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 React from "react";
import catalogCategoryRegistryInjectable from "../../../common/catalog/category-registry.injectable";
import { WeblinkAddCommand } from "../../components/catalog-entities/weblink-add-command";
import commandOverlayInjectable from "../../components/command-palette/command-overlay.injectable";
import { beforeFrameStartsSecondInjectionToken } from "../tokens";
const setupWeblickContextMenuOpenInjectable = getInjectable({
id: "setup-weblick-context-menu-open",
instantiate: (di) => ({
id: "setup-weblick-context-menu-open",
run: () => {
const catalogCategoryRegistry = di.inject(catalogCategoryRegistryInjectable);
const commandOverlay = di.inject(commandOverlayInjectable);
catalogCategoryRegistry
.getForGroupKind("entity.k8slens.dev", "WebLink")
?.on("catalogAddMenu", ctx => {
ctx.menuItems.push({
title: "Add web link",
icon: "public",
onClick: () => commandOverlay.open(<WeblinkAddCommand />),
});
});
},
}),
injectionToken: beforeFrameStartsSecondInjectionToken,
});
export default setupWeblickContextMenuOpenInjectable;
@@ -0,0 +1,36 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectionToken } from "@ogre-tools/injectable";
import type { Runnable } from "../../common/runnable/run-many-for";
// NOTE: these are run before any other token, mostly to set up things that all other runnables need
export const beforeFrameStartsFirstInjectionToken = getInjectionToken<Runnable>({
id: "before-frame-starts-first",
});
// NOTE: these are only run when process.isMainFrame === true
export const beforeMainFrameStartsFirstInjectionToken = getInjectionToken<Runnable>({
id: "before-main-frame-starts-first",
});
// NOTE: these are only run when process.isMainFrame === false
export const beforeClusterFrameStartsFirstInjectionToken = getInjectionToken<Runnable>({
id: "before-cluster-frame-starts-first",
});
export const beforeFrameStartsSecondInjectionToken = getInjectionToken<Runnable>({
id: "before-frame-starts-second",
});
// NOTE: these are only run when process.isMainFrame === true
export const beforeMainFrameStartsSecondInjectionToken = getInjectionToken<Runnable>({
id: "before-main-frame-starts-second",
});
// NOTE: these are only run when process.isMainFrame === false
export const beforeClusterFrameStartsSecondInjectionToken = getInjectionToken<Runnable>({
id: "before-cluster-frame-starts-second",
});
+75
View File
@@ -0,0 +1,75 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./components/app.scss";
import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { DefaultProps } from "./mui-base-theme";
import { DiContextProvider } from "@ogre-tools/injectable-react";
import type { DiContainer } from "@ogre-tools/injectable";
import extensionLoaderInjectable from "../extensions/extension-loader/extension-loader.injectable";
import extensionDiscoveryInjectable from "../extensions/extension-discovery/extension-discovery.injectable";
import extensionInstallationStateStoreInjectable from "../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import initRootFrameInjectable from "./frames/root-frame/init-root-frame.injectable";
import initClusterFrameInjectable from "./frames/cluster-frame/init-cluster-frame/init-cluster-frame.injectable";
import { Router } from "react-router";
import historyInjectable from "./navigation/history.injectable";
import assert from "assert";
import startFrameInjectable from "./start-frame/start-frame.injectable";
export async function bootstrap(di: DiContainer) {
const startFrame = di.inject(startFrameInjectable);
await startFrame();
const rootElem = document.getElementById("app");
assert(rootElem, "#app MUST exist");
const extensionLoader = di.inject(extensionLoaderInjectable);
extensionLoader.init();
const extensionDiscovery = di.inject(extensionDiscoveryInjectable);
extensionDiscovery.init();
const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
extensionInstallationStateStore.bindIpcListeners();
let App;
let initializeApp;
// TODO: Introduce proper architectural boundaries between root and cluster iframes
if (process.isMainFrame) {
initializeApp = di.inject(initRootFrameInjectable);
App = (await import("./frames/root-frame/root-frame")).RootFrame;
} else {
initializeApp = di.inject(initClusterFrameInjectable);
App = (await import("./frames/cluster-frame/cluster-frame")).ClusterFrame;
}
try {
await initializeApp(() => unmountComponentAtNode(rootElem));
} catch (error) {
console.error(`[BOOTSTRAP]: view initialization error: ${error}`, {
origin: location.href,
isTopFrameView: process.isMainFrame,
});
}
const history = di.inject(historyInjectable);
render(
<DiContextProvider value={{ di }}>
<Router history={history}>
{DefaultProps(App)}
</Router>
</DiContextProvider>,
rootElem,
);
}
@@ -0,0 +1,43 @@
/**
* 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 { CatalogEntityContextMenu } from "../api/catalog-entity";
import withConfirmationInjectable from "../components/confirm-dialog/with-confirm.injectable";
export interface NormalizedCatalogEntityContextMenu {
title: string;
icon?: string;
onClick: () => void;
}
export type NormalizeCatalogEntityContextMenu = (menuItem: CatalogEntityContextMenu) => NormalizedCatalogEntityContextMenu;
const normalizeCatalogEntityContextMenuInjectable = getInjectable({
id: "normalize-catalog-entity-context-menu",
instantiate: (di): NormalizeCatalogEntityContextMenu => {
const withConfirmation = di.inject(withConfirmationInjectable);
return (menuItem) => {
if (menuItem.confirm) {
return {
title: menuItem.title,
icon: menuItem.icon,
onClick: withConfirmation({
message: menuItem.confirm.message,
ok: menuItem.onClick,
okButtonProps: {
primary: false,
accent: true,
},
}),
};
}
return menuItem;
};
},
});
export default normalizeCatalogEntityContextMenuInjectable;
@@ -0,0 +1,18 @@
/**
* 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 { lensProxyCertificateChannel } from "../../common/certificate/lens-proxy-certificate-channel";
import requestFromChannelInjectable from "../utils/channel/request-from-channel.injectable";
const requestLensProxyCertificateInjectable = getInjectable({
id: "request-lens-proxy-certificate",
instantiate: (di) => {
const requestFromChannel = di.inject(requestFromChannelInjectable);
return () => requestFromChannel(lensProxyCertificateChannel);
},
});
export default requestLensProxyCertificateInjectable;
@@ -0,0 +1,28 @@
/**
* 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 catalogEntityRegistryInjectable from "../api/catalog/entity/registry.injectable";
import { computed } from "mobx";
import { isKubernetesCluster } from "../../common/catalog-entities";
const activeKubernetesClusterInjectable = getInjectable({
id: "active-kubernetes-cluster",
instantiate: (di) => {
const catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable);
return computed(() => {
const activeEntity = catalogEntityRegistry.activeEntity;
if (!isKubernetesCluster(activeEntity)) {
return null;
}
return activeEntity;
});
},
});
export default activeKubernetesClusterInjectable;
@@ -0,0 +1,16 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
/**
* This type is used for KubeObjectStores
*/
export interface ClusterContext {
readonly allNamespaces: string[]; // available / allowed namespaces from cluster.ts
readonly contextNamespaces: string[]; // selected by user (see: namespace-select.tsx)
readonly hasSelectedAll: boolean;
isLoadingAll(namespaces: string[]): boolean;
isGlobalWatchEnabled(): boolean;
}
@@ -0,0 +1,21 @@
/**
* 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 { ClusterContext } from "./cluster-frame-context";
const clusterFrameContextForClusterScopedResourcesInjectable = getInjectable({
id: "cluster-frame-context-for-cluster-scoped-resources",
instantiate: (): ClusterContext => ({
// This doesn't matter as it is an optimization for namespaced resources only
isGlobalWatchEnabled: () => true,
// This is always the case for cluster scoped resources
isLoadingAll: () => true,
allNamespaces: [],
contextNamespaces: [],
hasSelectedAll: true,
}),
});
export default clusterFrameContextForClusterScopedResourcesInjectable;
@@ -0,0 +1,64 @@
/**
* 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 { ClusterContext } from "./cluster-frame-context";
import namespaceStoreInjectable from "../components/+namespaces/store.injectable";
import hostedClusterInjectable from "./hosted-cluster.injectable";
import assert from "assert";
import { computed } from "mobx";
const clusterFrameContextForNamespacedResourcesInjectable = getInjectable({
id: "cluster-frame-context-for-namespaced-resources",
instantiate: (di): ClusterContext => {
const cluster = di.inject(hostedClusterInjectable);
const namespaceStore = di.inject(namespaceStoreInjectable);
assert(cluster, "This can only be injected within a cluster frame");
const allNamespaces = computed(() => {
// user given list of namespaces
if (cluster.accessibleNamespaces.length) {
return cluster.accessibleNamespaces.slice();
}
if (namespaceStore.items.length > 0) {
// namespaces from kubernetes api
return namespaceStore.items.map((namespace) => namespace.getName());
}
// fallback to cluster resolved namespaces because we could not load list
return cluster.allowedNamespaces.slice();
});
const contextNamespaces = computed(() => namespaceStore.contextNamespaces);
const hasSelectedAll = computed(() => {
const namespaces = new Set(contextNamespaces.get());
return allNamespaces.get().length > 1
&& cluster.accessibleNamespaces.length === 0
&& allNamespaces.get().every(ns => namespaces.has(ns));
});
return {
isLoadingAll: (namespaces) => (
allNamespaces.get().length > 1
&& cluster.accessibleNamespaces.length === 0
&& allNamespaces.get().every(ns => namespaces.includes(ns))
),
isGlobalWatchEnabled: () => cluster.isGlobalWatchEnabled,
get allNamespaces() {
return allNamespaces.get();
},
get contextNamespaces() {
return contextNamespaces.get();
},
get hasSelectedAll() {
return hasSelectedAll.get();
},
};
},
});
export default clusterFrameContextForNamespacedResourcesInjectable;
@@ -0,0 +1,14 @@
/**
* 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 { getClusterIdFromHost } from "../../common/utils";
const hostedClusterIdInjectable = getInjectable({
id: "hosted-cluster-id",
instantiate: () => getClusterIdFromHost(location.host),
causesSideEffects: true,
});
export default hostedClusterIdInjectable;
@@ -0,0 +1,20 @@
/**
* 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 hostedClusterIdInjectable from "./hosted-cluster-id.injectable";
import clusterStoreInjectable from "../../common/cluster-store/cluster-store.injectable";
const hostedClusterInjectable = getInjectable({
id: "hosted-cluster",
instantiate: (di) => {
const hostedClusterId = di.inject(hostedClusterIdInjectable);
const store = di.inject(clusterStoreInjectable);
return store.getById(hostedClusterId);
},
});
export default hostedClusterInjectable;
@@ -0,0 +1,27 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { computed } from "mobx";
import hostedClusterInjectable from "./hosted-cluster.injectable";
import { shouldShowResourceInjectionToken } from "../../common/cluster-store/allowed-resources-injection-token";
import type { KubeApiResourceDescriptor } from "../../common/rbac";
import { formatKubeApiResource } from "../../common/rbac";
const shouldShowResourceInjectable = getInjectable({
id: "should-show-resource",
instantiate: (di, resource) => {
const cluster = di.inject(hostedClusterInjectable);
return cluster
? computed(() => cluster.shouldShowResource(resource))
: computed(() => false);
},
injectionToken: shouldShowResourceInjectionToken,
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, resource: KubeApiResourceDescriptor) => formatKubeApiResource(resource),
}),
});
export default shouldShowResourceInjectable;
@@ -0,0 +1,18 @@
/**
* 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 { computed } from "mobx";
import hostedClusterInjectable from "../cluster-frame-context/hosted-cluster.injectable";
const clusterConfiguredAccessibleNamespacesInjectable = getInjectable({
id: "cluster-configured-accessible-namespaces",
instantiate: (di) => {
const hostedCluster = di.inject(hostedClusterInjectable);
return computed(() => [...hostedCluster?.accessibleNamespaces ?? []]);
},
});
export default clusterConfiguredAccessibleNamespacesInjectable;
@@ -0,0 +1,46 @@
/**
* 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 { ClusterDependencies } from "../../common/cluster/cluster";
import { Cluster } from "../../common/cluster/cluster";
import directoryForKubeConfigsInjectable from "../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable";
import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token";
import loggerInjectable from "../../common/logger.injectable";
import broadcastMessageInjectable from "../../common/ipc/broadcast-message.injectable";
import loadConfigfromFileInjectable from "../../common/kube-helpers/load-config-from-file.injectable";
const createClusterInjectable = getInjectable({
id: "create-cluster",
instantiate: (di) => {
const dependencies: ClusterDependencies = {
directoryForKubeConfigs: di.inject(directoryForKubeConfigsInjectable),
logger: di.inject(loggerInjectable),
broadcastMessage: di.inject(broadcastMessageInjectable),
loadConfigfromFile: di.inject(loadConfigfromFileInjectable),
// TODO: Dismantle wrong abstraction
// Note: "as never" to get around strictness in unnatural scenario
createKubeconfigManager: () => undefined as never,
createKubectl: () => { throw new Error("Tried to access back-end feature in front-end.");},
createContextHandler: () => undefined as never,
createAuthorizationReview: () => { throw new Error("Tried to access back-end feature in front-end."); },
requestNamespaceListPermissionsFor: () => { throw new Error("Tried to access back-end feature in front-end."); },
createListNamespaces: () => { throw new Error("Tried to access back-end feature in front-end."); },
requestApiResources: () => { throw new Error("Tried to access back-end feature in front-end."); },
detectClusterMetadata: () => { throw new Error("Tried to access back-end feature in front-end."); },
clusterVersionDetector: {
detect: () => { throw new Error("Tried to access back-end feature in front-end."); },
key: "irrelavent",
},
};
return (model, configData) => new Cluster(dependencies, model, configData);
},
injectionToken: createClusterInjectionToken,
});
export default createClusterInjectable;
@@ -0,0 +1,21 @@
/**
* 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 { AddCluster } from "./add-cluster";
import { routeSpecificComponentInjectionToken } from "../../routes/route-specific-component-injection-token";
import addClusterRouteInjectable from "../../../common/front-end-routing/routes/add-cluster/add-cluster-route.injectable";
const addClusterRouteComponentInjectable = getInjectable({
id: "add-cluster-route-component",
instantiate: (di) => ({
route: di.inject(addClusterRouteInjectable),
Component: AddCluster,
}),
injectionToken: routeSpecificComponentInjectionToken,
});
export default addClusterRouteComponentInjectable;
@@ -0,0 +1,31 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
@import "../../components/mixins.scss";
.AddClusters {
--flex-gap: calc(var(--unit) * 2);
code {
color: rgb(236, 64, 122);
}
a[href] {
color: var(--colorInfo);
}
}
.editor {
min-height: 600px;
max-height: 600px;
border: 1px solid var(--colorVague);
border-radius: var(--border-radius);
}
@include theme-light {
.editor {
border-color: var(--borderFaintColor);
}
}
@@ -0,0 +1,173 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./add-cluster.module.scss";
import type { KubeConfig } from "@kubernetes/client-node";
import fse from "fs-extra";
import { debounce } from "lodash";
import { action, computed, makeObservable, observable } from "mobx";
import { observer } from "mobx-react";
import React from "react";
import * as uuid from "uuid";
import { loadConfigFromString, splitConfig } from "../../../common/kube-helpers";
import { docsUrl } from "../../../common/vars";
import { isDefined, iter } from "../../utils";
import { Button } from "../button";
import type { ShowNotification } from "../notifications";
import { SettingLayout } from "../layout/setting-layout";
import { MonacoEditor } from "../monaco-editor";
import { withInjectables } from "@ogre-tools/injectable-react";
import getCustomKubeConfigFilePathInjectable from "../../../common/app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable";
import type { NavigateToCatalog } from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";
import navigateToCatalogInjectable from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";
import type { EmitAppEvent } from "../../../common/app-event-bus/emit-event.injectable";
import emitAppEventInjectable from "../../../common/app-event-bus/emit-event.injectable";
import type { GetDirnameOfPath } from "../../../common/path/get-dirname.injectable";
import getDirnameOfPathInjectable from "../../../common/path/get-dirname.injectable";
import showSuccessNotificationInjectable from "../notifications/show-success-notification.injectable";
import showErrorNotificationInjectable from "../notifications/show-error-notification.injectable";
interface Option {
config: KubeConfig;
error?: string;
}
interface Dependencies {
getCustomKubeConfigDirectory: (directoryName: string) => string;
navigateToCatalog: NavigateToCatalog;
getDirnameOfPath: GetDirnameOfPath;
emitAppEvent: EmitAppEvent;
showSuccessNotification: ShowNotification;
showErrorNotification: ShowNotification;
}
function getContexts(config: KubeConfig): Map<string, Option> {
return new Map(
splitConfig(config)
.map(({ config, validationResult }) => [config.currentContext, {
config,
error: validationResult.error?.toString(),
}]),
);
}
@observer
class NonInjectedAddCluster extends React.Component<Dependencies> {
@observable kubeContexts = observable.map<string, Option>();
@observable customConfig = "";
@observable isWaiting = false;
@observable errors: string[] = [];
constructor(props: Dependencies) {
super(props);
makeObservable(this);
}
componentDidMount() {
this.props.emitAppEvent({ name: "cluster-add", action: "start" });
}
@computed get allErrors(): string[] {
return [
...this.errors,
...iter.map(this.kubeContexts.values(), ({ error }) => error),
].filter(isDefined);
}
readonly refreshContexts = debounce(action(() => {
const { config, error } = loadConfigFromString(this.customConfig.trim() || "{}");
this.kubeContexts.replace(getContexts(config));
if (error) {
this.errors.push(error.toString());
}
if (config.contexts.length === 0) {
this.errors.push('No contexts defined, either missing the "contexts" field, or it is empty.');
}
}), 500);
addClusters = action(async () => {
this.isWaiting = true;
this.props.emitAppEvent({ name: "cluster-add", action: "click" });
try {
const absPath = this.props.getCustomKubeConfigDirectory(uuid.v4());
await fse.ensureDir(this.props.getDirnameOfPath(absPath));
await fse.writeFile(absPath, this.customConfig.trim(), { encoding: "utf-8", mode: 0o600 });
this.props.showSuccessNotification(`Successfully added ${this.kubeContexts.size} new cluster(s)`);
return this.props.navigateToCatalog();
} catch (error) {
this.props.showErrorNotification(`Failed to add clusters: ${error}`);
}
});
render() {
return (
<SettingLayout className={styles.AddClusters} data-testid="add-cluster-page">
<h2>Add Clusters from Kubeconfig</h2>
<p>
{"Clusters added here are "}
<b>not</b>
{" merged into the "}
<code>~/.kube/config</code>
{" file. "}
<a
href={`${docsUrl}/getting-started/add-cluster/`}
rel="noreferrer"
target="_blank"
>
Read more about adding clusters.
</a>
</p>
<div className="flex column">
<MonacoEditor
autoFocus
className={styles.editor}
value={this.customConfig}
onChange={value => {
this.customConfig = value;
this.errors.length = 0;
this.refreshContexts();
}}
/>
</div>
{this.allErrors.length > 0 && (
<>
<h3>KubeConfig Yaml Validation Errors:</h3>
{this.allErrors.map(error => <div key={error} className="error">{error}</div>)}
</>
)}
<div className="actions-panel">
<Button
primary
disabled={this.kubeContexts.size === 0}
label={this.kubeContexts.size === 1 ? "Add cluster" : "Add clusters"}
onClick={this.addClusters}
waiting={this.isWaiting}
tooltip={this.kubeContexts.size === 0 || "Paste in at least one cluster to add."}
tooltipOverrideDisabled
/>
</div>
</SettingLayout>
);
}
}
export const AddCluster = withInjectables<Dependencies>(NonInjectedAddCluster, {
getProps: (di) => ({
getCustomKubeConfigDirectory: di.inject(getCustomKubeConfigFilePathInjectable),
navigateToCatalog: di.inject(navigateToCatalogInjectable),
getDirnameOfPath: di.inject(getDirnameOfPathInjectable),
emitAppEvent: di.inject(emitAppEventInjectable),
showSuccessNotification: di.inject(showSuccessNotificationInjectable),
showErrorNotification: di.inject(showErrorNotificationInjectable),
}),
});
@@ -0,0 +1,6 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export * from "./add-cluster";
@@ -0,0 +1,89 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import React from "react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { CatalogCategorySpec } from "../../../../common/catalog";
import { CatalogCategory } from "../../../../common/catalog";
import { CatalogAddButton } from "../catalog-add-button";
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import { type DiRender, renderFor } from "../../test-utils/renderFor";
class TestCatalogCategory extends CatalogCategory {
public readonly apiVersion = "catalog.k8slens.dev/v1alpha1";
public readonly kind = "CatalogCategory";
public metadata = {
name: "Test Category",
icon: "",
};
public spec: CatalogCategorySpec = {
group: "entity.k8slens.dev",
versions: [],
names: {
kind: "Test",
},
};
}
describe("CatalogAddButton", () => {
let render: DiRender;
beforeEach(() => {
const di = getDiForUnitTesting({ doGeneralOverrides: true });
render = renderFor(di);
});
it("opens Add menu", async () => {
const category = new TestCatalogCategory();
category.on("catalogAddMenu", ctx => {
ctx.menuItems.push(
{
icon: "text_snippet",
title: "Add from kubeconfig",
onClick: () => {},
},
);
});
render(<CatalogAddButton category={category}/>);
userEvent.hover(screen.getByLabelText("SpeedDial CatalogAddButton"));
await screen.findByTitle("Add from kubeconfig");
});
it("filters menu items", async () => {
const category = new TestCatalogCategory();
category.on("catalogAddMenu", ctx => {
ctx.menuItems.push(
{
icon: "text_snippet",
title: "foobar",
onClick: () => {},
},
);
ctx.menuItems.push(
{
icon: "text_snippet",
title: "Add from kubeconfig",
onClick: () => {},
},
);
});
category.addMenuFilter(item => item.title === "foobar");
render(<CatalogAddButton category={category}/>);
userEvent.hover(screen.getByLabelText("SpeedDial CatalogAddButton"));
await expect(screen.findByTitle("Add from kubeconfig"))
.rejects
.toThrow();
await screen.findByTitle("foobar");
});
});
@@ -0,0 +1,54 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { render, screen } from "@testing-library/react";
import React from "react";
import "@testing-library/jest-dom/extend-expect";
import type { CatalogCategorySpec } from "../../../../common/catalog";
import { CatalogCategory } from "../../../../common/catalog";
import { CatalogCategoryLabel } from "../catalog-category-label";
class CatalogCategoryWithoutBadge extends CatalogCategory {
public readonly apiVersion = "catalog.k8slens.dev/v1alpha1";
public readonly kind = "CatalogCategory";
public metadata = {
name: "Test Category",
icon: "",
};
public spec: CatalogCategorySpec = {
group: "entity.k8slens.dev",
versions: [],
names: {
kind: "Test",
},
};
}
class CatalogCategoryWithBadge extends CatalogCategoryWithoutBadge {
getBadge() {
return (<div>Test Badge</div>);
}
}
describe("CatalogCategoryLabel", () => {
it("renders without a badge", async () => {
const category = new CatalogCategoryWithoutBadge();
render(<CatalogCategoryLabel category={category}/>);
expect(await screen.findByText("Test Category")).toBeInTheDocument();
});
it("renders with a badge", async () => {
const category = new CatalogCategoryWithBadge();
render(<CatalogCategoryLabel category={category}/>);
expect(await screen.findByText("Test Category")).toBeInTheDocument();
expect(await screen.findByText("Test Badge")).toBeInTheDocument();
});
});
@@ -0,0 +1,163 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { CatalogCategoryMetadata, CatalogCategorySpec } from "../../../../common/catalog";
import { CatalogEntity, categoryVersion } from "../../../../common/catalog";
import { CatalogCategory } from "../../../api/catalog-entity";
import { noop } from "../../../utils";
import type { CatalogEntityStore } from "../catalog-entity-store/catalog-entity.store";
import { catalogEntityStore } from "../catalog-entity-store/catalog-entity.store";
class TestEntityOne extends CatalogEntity {
public static readonly apiVersion: string = "entity.k8slens.dev/v1alpha1";
public static readonly kind: string = "TestEntityOne";
public readonly apiVersion = TestEntityOne.apiVersion;
public readonly kind = TestEntityOne.kind;
}
class TestEntityTwo extends CatalogEntity {
public static readonly apiVersion: string = "entity.k8slens.dev/v1alpha1";
public static readonly kind: string = "TestEntityTwo";
public readonly apiVersion = TestEntityTwo.apiVersion;
public readonly kind = TestEntityTwo.kind;
}
class TestCategoryOne extends CatalogCategory {
apiVersion = "catalog.k8slens.dev/v1alpha1";
kind = "CatalogCategory";
metadata: CatalogCategoryMetadata = {
icon: "dash",
name: "test-one",
};
spec: CatalogCategorySpec = {
group: "entity.k8slens.dev",
versions: [
categoryVersion("v1alpha1", TestEntityOne),
],
names: {
kind: "KubernetesCluster",
},
};
}
class TestCategoryTwo extends CatalogCategory {
apiVersion = "catalog.k8slens.dev/v1alpha1";
kind = "CatalogCategory";
metadata: CatalogCategoryMetadata = {
icon: "dash",
name: "test-two",
};
spec: CatalogCategorySpec = {
group: "entity.k8slens.dev",
versions: [
categoryVersion("v1alpha1", TestEntityTwo),
],
names: {
kind: "KubernetesCluster",
},
};
}
describe("CatalogEntityStore", () => {
describe("getTotalCount", () => {
let store: CatalogEntityStore;
let testCategoryOne: TestCategoryOne;
let testCategoryTwo: TestCategoryTwo;
beforeEach(() => {
const entityItems = [
new TestEntityOne({
metadata: {
labels: {},
name: "my-test-one",
uid: "1",
},
spec: {},
status: {
phase: "unknown",
},
}),
new TestEntityOne({
metadata: {
labels: {},
name: "my-test-two",
uid: "2",
},
spec: {},
status: {
phase: "unknown",
},
}),
new TestEntityTwo({
metadata: {
labels: {},
name: "my-test-three",
uid: "3",
},
spec: {},
status: {
phase: "unknown",
},
}),
new TestEntityTwo({
metadata: {
labels: {},
name: "my-test-four",
uid: "4",
},
spec: {},
status: {
phase: "unknown",
},
}),
new TestEntityTwo({
metadata: {
labels: {},
name: "my-test-five",
uid: "5",
},
spec: {},
status: {
phase: "unknown",
},
}),
];
testCategoryOne = new TestCategoryOne();
testCategoryTwo = new TestCategoryTwo();
store = catalogEntityStore({
catalogRegistry: {
items: [
testCategoryOne,
testCategoryTwo,
],
},
entityRegistry: {
onRun: noop,
filteredItems: entityItems,
getItemsForCategory: <T extends CatalogEntity>(category: CatalogCategory): T[] => {
return entityItems.filter(item => category.spec.versions.some(version => item instanceof version.entityClass)) as T[];
},
},
});
});
it("given no active category, returns count of all kinds", () => {
expect(store.getTotalCount()).toBe(5);
});
it("given active category is TestCategoryOne, only returns count for those declared kinds", () => {
store.activeCategory.set(testCategoryOne);
expect(store.getTotalCount()).toBe(2);
});
it("given active category is TestCategoryTwo, only returns count for those declared kinds", () => {
store.activeCategory.set(testCategoryTwo);
expect(store.getTotalCount()).toBe(3);
});
});
});
@@ -0,0 +1,137 @@
/**
* 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 { CatalogCategorySpec } from "../../../../common/catalog";
import { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
import { CatalogCategory } from "../../../api/catalog-entity";
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import type { AdditionalCategoryColumnRegistration, CategoryColumnRegistration } from "../custom-category-columns";
import type { CategoryColumns, GetCategoryColumnsParams } from "../columns/get.injectable";
import getCategoryColumnsInjectable from "../columns/get.injectable";
import hotbarStoreInjectable from "../../../../common/hotbars/store.injectable";
import extensionInjectable from "../../../../extensions/extension-loader/extension/extension.injectable";
import currentlyInClusterFrameInjectable from "../../../routes/currently-in-cluster-frame.injectable";
class TestCategory extends CatalogCategory {
apiVersion = "catalog.k8slens.dev/v1alpha1";
kind = "CatalogCategory";
metadata = {
name: "Test",
icon: "question_mark",
};
spec: CatalogCategorySpec = {
group: "foo.bar.bat",
names: {
kind: "Test",
},
versions: [],
};
constructor(columns?: CategoryColumnRegistration[]) {
super();
this.spec = {
displayColumns: columns,
...this.spec,
};
}
}
describe("Custom Category Columns", () => {
let di: DiContainer;
let getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns;
beforeEach(() => {
di = getDiForUnitTesting({ doGeneralOverrides: true });
di.override(hotbarStoreInjectable, () => ({}));
di.override(currentlyInClusterFrameInjectable, () => false);
getCategoryColumns = di.inject(getCategoryColumnsInjectable);
});
describe("without extensions", () => {
it("should contain a kind column if activeCategory is falsy", () => {
expect(getCategoryColumns({ activeCategory: null }).renderTableHeader.find(elem => elem?.title === "Kind")).toBeTruthy();
});
it("should not contain a kind column if activeCategory is truthy", () => {
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "Kind")).toBeFalsy();
});
it("should include the default columns if the provided category doesn't provide any", () => {
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "Source")).toBeTruthy();
});
it("should not include the default columns if the provided category provides any", () => {
expect(getCategoryColumns({ activeCategory: new TestCategory([]) }).renderTableHeader.find(elem => elem?.title === "Source")).toBeFalsy();
});
it("should include the displayColumns from the provided category", () => {
const columns: CategoryColumnRegistration[] = [
{
id: "foo",
renderCell: () => null,
titleProps: {
title: "Foo",
},
},
];
expect(getCategoryColumns({ activeCategory: new TestCategory(columns) }).renderTableHeader.find(elem => elem?.title === "Foo")).toBeTruthy();
});
});
describe("with extensions", () => {
beforeEach(() => {
const ext = di.inject(extensionInjectable, new (class extends LensRendererExtension {
additionalCategoryColumns = [
{
group: "foo.bar.bat",
id: "high",
kind: "Test",
renderCell: () => "",
titleProps: {
title: "High",
},
} as AdditionalCategoryColumnRegistration,
{
group: "foo.bar",
id: "high",
kind: "Test",
renderCell: () => "",
titleProps: {
title: "High2",
},
} as AdditionalCategoryColumnRegistration,
];
})({
absolutePath: "/some-absolute-path",
id: "some-id",
isBundled: false,
isCompatible: true,
isEnabled: true,
manifest: {
engines: {
lens: "",
},
name: "some-extension-name",
version: "1.0.0",
},
manifestPath: "/some-manifest-path",
}));
ext.register();
});
it("should include columns from extensions that match", () => {
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "High")).toBeTruthy();
});
it("should not include columns from extensions that don't match", () => {
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "High2")).toBeFalsy();
});
});
});
@@ -0,0 +1,96 @@
/**
* 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 { computed } from "mobx";
import type React from "react";
import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable";
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import type { CustomCategoryViewRegistration } from "../custom-views";
import customCategoryViewsInjectable from "../custom-views.injectable";
describe("Custom Category Views", () => {
let di: DiContainer;
beforeEach(() => {
di = getDiForUnitTesting({ doGeneralOverrides: true });
});
it("should order items correctly over all extensions", () => {
const component1 = (): React.ReactNode => null;
const component2 = (): React.ReactNode => null;
di.override(rendererExtensionsInjectable, () => computed(() => [
{
customCategoryViews: [
{
components: {
View: component1,
},
group: "foo",
kind: "bar",
priority: 100,
} as CustomCategoryViewRegistration,
],
},
{
customCategoryViews: [
{
components: {
View: component2,
},
group: "foo",
kind: "bar",
priority: 95,
} as CustomCategoryViewRegistration,
],
},
] as LensRendererExtension[]));
const customCategoryViews = di.inject(customCategoryViewsInjectable);
const { after = [] } = customCategoryViews.get().get("foo")?.get("bar") ?? {};
expect(after[0].View).toBe(component2);
expect(after[1].View).toBe(component1);
});
it("should put put priority < 50 items in before", () => {
const component1 = (): React.ReactNode => null;
const component2 = (): React.ReactNode => null;
di.override(rendererExtensionsInjectable, () => computed(() => [
{
customCategoryViews: [
{
components: {
View: component1,
},
group: "foo",
kind: "bar",
priority: 40,
} as CustomCategoryViewRegistration,
],
},
{
customCategoryViews: [
{
components: {
View: component2,
},
group: "foo",
kind: "bar",
priority: 95,
} as CustomCategoryViewRegistration,
],
},
] as LensRendererExtension[]));
const customCategoryViews = di.inject(customCategoryViewsInjectable);
const { before = [] } = customCategoryViews.get().get("foo")?.get("bar") ?? {};
expect(before[0].View).toBe(component1);
});
});
@@ -0,0 +1,30 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.CatalogAddButton {
position: absolute;
right: 40px;
bottom: 30px;
.MuiFab-primary {
background-color: var(--blue);
border-radius: 50%;
&:hover {
background-color: #317bb3;
}
}
}
.catalogSpeedDialPopper {
.MuiTooltip-tooltip {
background-color: #222;
font-size: 12px
}
.MuiTooltip-arrow {
color: #222;
}
}
@@ -0,0 +1,150 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./catalog-add-button.scss";
import React from "react";
import { SpeedDial, SpeedDialAction } from "@material-ui/lab";
import { Icon } from "../icon";
import { observer } from "mobx-react";
import { observable, makeObservable, action } from "mobx";
import { autoBind } from "../../../common/utils";
import type { CatalogCategory, CatalogEntityAddMenu } from "../../api/catalog-entity";
import { EventEmitter } from "events";
import type { CatalogCategoryRegistry } from "../../../common/catalog";
import { withInjectables } from "@ogre-tools/injectable-react";
import catalogCategoryRegistryInjectable from "../../../common/catalog/category-registry.injectable";
import type { Navigate } from "../../navigation/navigate.injectable";
import navigateInjectable from "../../navigation/navigate.injectable";
export interface CatalogAddButtonProps {
category: CatalogCategory;
}
type CategoryId = string;
interface Dependencies {
catalogCategoryRegistry: CatalogCategoryRegistry;
navigate: Navigate;
}
@observer
class NonInjectedCatalogAddButton extends React.Component<CatalogAddButtonProps & Dependencies> {
@observable protected isOpen = false;
@observable menuItems = new Map<CategoryId, CatalogEntityAddMenu[]>();
constructor(props: CatalogAddButtonProps & Dependencies) {
super(props);
makeObservable(this);
autoBind(this);
}
componentDidMount() {
this.updateMenuItems();
}
componentDidUpdate(prevProps: CatalogAddButtonProps) {
if (prevProps.category != this.props.category) {
this.updateMenuItems();
}
}
get categories() {
return this.props.catalogCategoryRegistry.filteredItems;
}
@action
updateMenuItems() {
this.menuItems.clear();
if (this.props.category) {
this.updateCategoryItems(this.props.category);
} else {
// Show menu items from all categories
this.categories.forEach(this.updateCategoryItems);
}
}
updateCategoryItems = action((category: CatalogCategory) => {
if (category instanceof EventEmitter) {
const menuItems: CatalogEntityAddMenu[] = [];
category.emit("catalogAddMenu", {
navigate: this.props.navigate,
menuItems,
});
this.menuItems.set(category.getId(), menuItems);
}
});
getCategoryFilteredItems = (category: CatalogCategory) => {
return category.filteredItems(this.menuItems.get(category.getId()) || []);
};
onOpen() {
this.isOpen = true;
}
onClose() {
this.isOpen = false;
}
onButtonClick() {
const defaultAction = this.items.find(item => item.defaultAction)?.onClick;
const clickAction = defaultAction || (this.items.length === 1 ? this.items[0].onClick : null);
clickAction?.();
}
get items() {
const { category } = this.props;
return category ? this.getCategoryFilteredItems(category) :
this.categories.map(this.getCategoryFilteredItems).flat();
}
render() {
if (this.items.length === 0) {
return null;
}
return (
<SpeedDial
className="CatalogAddButton"
ariaLabel="SpeedDial CatalogAddButton"
open={this.isOpen}
onOpen={this.onOpen}
onClose={this.onClose}
icon={<Icon material="add" />}
direction="up"
onClick={this.onButtonClick}
>
{this.items.map((menuItem, index) => {
return (
<SpeedDialAction
key={index}
icon={<Icon material={menuItem.icon}/>}
tooltipTitle={menuItem.title}
onClick={(evt) => {
evt.stopPropagation();
menuItem.onClick();
}}
TooltipClasses={{
popper: "catalogSpeedDialPopper",
}}
/>
);
})}
</SpeedDial>
);
}
}
export const CatalogAddButton = withInjectables<Dependencies, CatalogAddButtonProps>(NonInjectedCatalogAddButton, {
getProps: (di, props) => ({
...props,
catalogCategoryRegistry: di.inject(catalogCategoryRegistryInjectable),
navigate: di.inject(navigateInjectable),
}),
});
@@ -0,0 +1,6 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export const browseCatalogTab = "browse";
@@ -0,0 +1,24 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import React from "react";
import type { CatalogCategory } from "../../../common/catalog/catalog-entity";
interface CatalogCategoryLabelProps {
category: CatalogCategory;
}
/**
* Display label for Catalog Category for the Catalog menu
*/
export const CatalogCategoryLabel = ({ category }: CatalogCategoryLabelProps) => {
const badge = category.getBadge();
return (
<div className="flex">
<div>{category.metadata.name}</div>
{badge ? (<div className="flex items-center">{badge}</div>) : null}
</div>
);
};
@@ -0,0 +1,116 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import React from "react";
import { cssNames, hasDefiniteField } from "../../utils";
import type { MenuActionsProps } from "../menu/menu-actions";
import { MenuActions } from "../menu/menu-actions";
import type { CatalogEntity, CatalogEntityContextMenu } from "../../api/catalog-entity";
import { observer } from "mobx-react";
import { observable } from "mobx";
import { MenuItem } from "../menu";
import { Icon } from "../icon";
import { HotbarToggleMenuItem } from "./hotbar-toggle-menu-item";
import type { VisitEntityContextMenu } from "../../../common/catalog/visit-entity-context-menu.injectable";
import visitEntityContextMenuInjectable from "../../../common/catalog/visit-entity-context-menu.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import type { Navigate } from "../../navigation/navigate.injectable";
import navigateInjectable from "../../navigation/navigate.injectable";
import type { NormalizeCatalogEntityContextMenu } from "../../catalog/normalize-menu-item.injectable";
import normalizeCatalogEntityContextMenuInjectable from "../../catalog/normalize-menu-item.injectable";
export interface CatalogEntityDrawerMenuProps<Entity extends CatalogEntity> extends MenuActionsProps {
entity: Entity;
}
interface Dependencies {
normalizeMenuItem: NormalizeCatalogEntityContextMenu;
navigate: Navigate;
visitEntityContextMenu: VisitEntityContextMenu;
}
@observer
class NonInjectedCatalogEntityDrawerMenu<T extends CatalogEntity> extends React.Component<CatalogEntityDrawerMenuProps<T> & Dependencies> {
private readonly menuItems = observable.array<CatalogEntityContextMenu>();
componentDidMount() {
this.props.visitEntityContextMenu(this.props.entity, {
menuItems: this.menuItems,
navigate: this.props.navigate,
});
}
getMenuItems(entity: T): React.ReactChild[] {
if (!entity) {
return [];
}
const items = this.menuItems
.map(this.props.normalizeMenuItem)
.filter(hasDefiniteField("icon"))
.map(menuItem => (
<MenuItem key={menuItem.title} onClick={menuItem.onClick}>
<Icon
interactive
tooltip={menuItem.title}
{...{ [Icon.isSvg(menuItem.icon) ? "svg" : "material"]: menuItem.icon }}
/>
</MenuItem>
));
items.push(
<HotbarToggleMenuItem
key="hotbar-toggle"
entity={entity}
addContent={(
<Icon
material="push_pin"
interactive
small
tooltip="Add to Hotbar"
/>
)}
removeContent={(
<Icon
svg="push_off"
interactive
small
tooltip="Remove from Hotbar"
/>
)}
/>,
);
return items;
}
render() {
const { className, entity, ...menuProps } = this.props;
if (!this.menuItems.length || !entity.isEnabled()) {
return null;
}
return (
<MenuActions
id={`menu-actions-for-catalog-entity-drawer-menu-${entity.getId()}`}
className={cssNames("CatalogEntityDrawerMenu", className)}
toolbar
{...menuProps}
>
{this.getMenuItems(entity)}
</MenuActions>
);
}
}
export const CatalogEntityDrawerMenu = withInjectables<Dependencies, CatalogEntityDrawerMenuProps<CatalogEntity>>(NonInjectedCatalogEntityDrawerMenu, {
getProps: (di, props) => ({
...props,
visitEntityContextMenu: di.inject(visitEntityContextMenuInjectable),
normalizeMenuItem: di.inject(normalizeCatalogEntityContextMenuInjectable),
navigate: di.inject(navigateInjectable),
}),
}) as <Entity extends CatalogEntity>(props: CatalogEntityDrawerMenuProps<Entity>) => JSX.Element;
@@ -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 { catalogEntityStore } from "./catalog-entity.store";
import catalogEntityRegistryInjectable from "../../../api/catalog/entity/registry.injectable";
import catalogCategoryRegistryInjectable from "../../../../common/catalog/category-registry.injectable";
const catalogEntityStoreInjectable = getInjectable({
id: "catalog-entity-store",
instantiate: (di) => catalogEntityStore({
entityRegistry: di.inject(catalogEntityRegistryInjectable),
catalogRegistry: di.inject(catalogCategoryRegistryInjectable),
}),
});
export default catalogEntityStoreInjectable;
@@ -0,0 +1,86 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { IComputedValue, IObservableValue } from "mobx";
import { computed, observable, reaction } from "mobx";
import type { CatalogEntityRegistry } from "../../../api/catalog/entity/registry";
import type { CatalogEntity } from "../../../api/catalog-entity";
import type { CatalogCategory, CatalogCategoryRegistry } from "../../../../common/catalog";
import type { Disposer } from "../../../../common/utils";
import { disposer } from "../../../../common/utils";
import type { ItemListStore } from "../../item-object-list";
type EntityRegistry = Pick<CatalogEntityRegistry, "getItemsForCategory" | "filteredItems" | "onRun">;
type CatalogRegistry = Pick<CatalogCategoryRegistry, "items">;
interface Dependencies {
entityRegistry: EntityRegistry;
catalogRegistry: CatalogRegistry;
}
export type CatalogEntityStore = ItemListStore<CatalogEntity, false> & {
readonly entities: IComputedValue<CatalogEntity[]>;
readonly activeCategory: IObservableValue<CatalogCategory | undefined>;
readonly selectedItemId: IObservableValue<string | undefined>;
readonly selectedItem: IComputedValue<CatalogEntity | undefined>;
watch(): Disposer;
onRun(entity: CatalogEntity): void;
};
export function catalogEntityStore({
entityRegistry,
catalogRegistry,
}: Dependencies): CatalogEntityStore {
const activeCategory = observable.box<CatalogCategory | undefined>(undefined);
const selectedItemId = observable.box<string | undefined>(undefined);
const entities = computed(() => {
const category = activeCategory.get();
return category
? entityRegistry.getItemsForCategory(category, { filtered: true })
: entityRegistry.filteredItems;
});
const selectedItem = computed(() => {
const id = selectedItemId.get();
if (!id) {
return undefined;
}
return entities.get().find(entity => entity.getId() === id);
});
const loadAll = () => {
const category = activeCategory.get();
if (category) {
category.emit("load");
} else {
for (const category of catalogRegistry.items) {
category.emit("load");
}
}
};
return {
entities,
selectedItem,
activeCategory,
selectedItemId,
watch: () => disposer(
reaction(() => entities.get(), loadAll),
reaction(() => activeCategory.get(), loadAll, { delay: 100 }),
),
onRun: entity => entityRegistry.onRun(entity),
failedLoading: false,
getTotalCount: () => entities.get().length,
isLoaded: true,
isSelected: (item) => item.getId() === selectedItemId.get(),
isSelectedAll: () => false,
pickOnlySelected: () => [],
toggleSelection: () => {},
toggleSelectionAll: () => {},
removeSelectedItems: async () => {},
};
}
@@ -0,0 +1,22 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.bordered {
border-top: 1px solid var(--inputControlBorder);
}
.parent {
color: var(--textColorAccent);
font-size: small;
text-transform: uppercase;
font-weight: bold;
}
.catalog {
font-size: medium;
padding: var(--padding) calc(var(--padding) * 1.5);
font-weight: bold;
color: var(--textColorAccent);
}
@@ -0,0 +1,97 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import treeStyles from "./catalog-tree.module.scss";
import styles from "./catalog-menu.module.scss";
import React from "react";
import type { TreeItemProps } from "@material-ui/lab";
import { TreeItem, TreeView } from "@material-ui/lab";
import { Icon } from "../icon";
import { StylesProvider } from "@material-ui/core";
import { cssNames } from "../../utils";
import type { CatalogCategory } from "../../api/catalog-entity";
import { observer } from "mobx-react";
import { CatalogCategoryLabel } from "./catalog-category-label";
import type { IComputedValue } from "mobx";
import { withInjectables } from "@ogre-tools/injectable-react";
import filteredCategoriesInjectable from "../../../common/catalog/filtered-categories.injectable";
export interface CatalogMenuProps {
activeTab: string | undefined;
onItemClick: (id: string) => void;
}
function getCategoryIcon(category: CatalogCategory) {
const { icon } = category.metadata ?? {};
if (typeof icon === "string") {
return Icon.isSvg(icon)
? <Icon small svg={icon}/>
: <Icon small material={icon}/>;
}
return null;
}
function Item(props: TreeItemProps) {
return (
<TreeItem classes={treeStyles} {...props}/>
);
}
interface Dependencies {
filteredCategories: IComputedValue<CatalogCategory[]>;
}
const NonInjectedCatalogMenu = observer(({
activeTab,
filteredCategories,
onItemClick,
}: CatalogMenuProps & Dependencies) => (
// Overwrite Material UI styles with injectFirst https://material-ui.com/guides/interoperability/#controlling-priority-4
<StylesProvider injectFirst>
<div className="flex flex-col w-full">
<div className={styles.catalog}>Catalog</div>
<TreeView
defaultExpanded={["catalog"]}
defaultCollapseIcon={<Icon material="expand_more" />}
defaultExpandIcon={<Icon material="chevron_right" />}
selected={activeTab || "browse"}
>
<Item
nodeId="browse"
label="Browse"
data-testid="*-tab"
onClick={() => onItemClick("*")} />
<Item
nodeId="catalog"
label={<div className={styles.parent}>Categories</div>}
className={cssNames(styles.bordered)}
>
{
filteredCategories.get()
.map(category => (
<Item
icon={getCategoryIcon(category)}
key={category.getId()}
nodeId={category.getId()}
label={<CatalogCategoryLabel category={category} />}
data-testid={`${category.getId()}-tab`}
onClick={() => onItemClick(category.getId())} />
))
}
</Item>
</TreeView>
</div>
</StylesProvider>
));
export const CatalogMenu = withInjectables<Dependencies, CatalogMenuProps>(NonInjectedCatalogMenu, {
getProps: (di, props) => ({
...props,
filteredCategories: di.inject(filteredCategoriesInjectable),
}),
});
@@ -0,0 +1,22 @@
/**
* 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 createStorageInjectable from "../../../utils/create-storage/create-storage.injectable";
import { browseCatalogTab } from "../catalog-browse-tab";
const catalogPreviousActiveTabStorageInjectable = getInjectable({
id: "catalog-previous-active-tab-storage",
instantiate: (di) => {
const createStorage = di.inject(createStorageInjectable);
return createStorage<string | null>(
"catalog-previous-active-tab",
browseCatalogTab,
);
},
});
export default catalogPreviousActiveTabStorageInjectable;
@@ -0,0 +1,21 @@
/**
* 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 { Catalog } from "./catalog";
import catalogRouteInjectable from "../../../common/front-end-routing/routes/catalog/catalog-route.injectable";
import { routeSpecificComponentInjectionToken } from "../../routes/route-specific-component-injection-token";
const catalogRouteComponentInjectable = getInjectable({
id: "catalog-route-component",
instantiate: (di) => ({
route: di.inject(catalogRouteInjectable),
Component: Catalog,
}),
injectionToken: routeSpecificComponentInjectionToken,
});
export default catalogRouteComponentInjectable;
@@ -0,0 +1,24 @@
/**
* 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 { computed } from "mobx";
import routePathParametersInjectable from "../../routes/route-path-parameters.injectable";
import catalogRouteInjectable from "../../../common/front-end-routing/routes/catalog/catalog-route.injectable";
const catalogRouteParametersInjectable = getInjectable({
id: "catalog-route-parameters",
instantiate: (di) => {
const route = di.inject(catalogRouteInjectable);
const pathParameters = di.inject(routePathParametersInjectable, route);
return {
group: computed(() => pathParameters.get().group),
kind: computed(() => pathParameters.get().kind),
};
},
});
export default catalogRouteParametersInjectable;
@@ -0,0 +1,61 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
/* This file overwrites basic Material UI TreeView styles. Can contain only following
classnames: root, expanded, selected, content, iconContainer, label, group
*/
.root {
color: var(--textColorTertiary);
}
.label {
font-size: var(--font-size)!important;
background-color: transparent!important;
&:hover {
background-color: transparent;
}
}
.content {
min-height: 26px;
line-height: 1.3;
padding: 2px var(--padding) 2px 0;
&:hover {
background-color: var(--sidebarItemHoverBackground);
border-radius: 2px;
}
&:active {
color: white;
background-color: var(--blue);
}
}
.group {
margin-left: 0px;
.iconContainer {
margin-left: 28px;
margin-top: 2px;
align-self: flex-start;
}
}
.selected {
& > *:first-child {
background-color: var(--blue);
color: white;
border-radius: 2px;
}
}
.iconContainer {
width: 21px;
margin-left: 5px;
margin-right: 0;
}
@@ -0,0 +1,138 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.Catalog {
:global(.TableRow):hover .pinIcon {
opacity: 1;
}
flex-grow: 1;
}
.entityName {
position: relative;
width: 100%;
max-width: 100%;
display: flex;
align-items: center;
padding: 0 var(--padding);
padding-bottom: 0;
padding-right: 24px; // + reserved space for .pinIcon
flex-grow: 2.5!important;
> span {
overflow: hidden;
text-overflow: ellipsis;
padding-left: var(--padding);
}
:global(.HotbarIcon){
align-self: center;
div {
/* icons with plain text */
font-size: var(--unit);
}
.Icon {
/* icons with font-icon */
font-size: var(--small-size);
}
}
.pinIcon {
--color-active: var(--textColorAccent);
transition: none;
opacity: 0;
margin-left: var(--padding);
width: calc(var(--unit) * 2);
&:hover {
/* Drop styles defined for <Icon/> */
background-color: transparent;
box-shadow: none;
}
}
}
.sourceCell {
max-width: 100px;
}
.statusCell {
max-width: 100px;
:global {
.connected, .available {
color: var(--colorSuccess);
}
.disconnected, .deleting, .unavailable {
color: var(--halfGray);
}
}
}
.labelsCell {
overflow-x: scroll;
text-overflow: unset;
}
.labelsCell::-webkit-scrollbar {
display: none;
}
.badge {
overflow: unset;
text-overflow: unset;
max-width: unset;
}
.badge:hover {
color: var(--textColorAccent);
}
.badge:not(:first-child) {
margin-left: 0.5em;
}
.catalogIcon {
font-size: 10px;
-webkit-font-smoothing: auto;
}
.tabs {
@apply flex flex-grow flex-col;
}
.tab {
@apply px-8 py-4;
}
.tab:hover {
background-color: var(--sidebarItemHoverBackground);
--color-active: var(--textColorTertiary);
}
.tab::after {
display: none;
}
.activeTab, .activeTab:hover {
background-color: var(--blue);
--color-active: white;
}
.catalogAvatar {
font-size: 1.2ch;
}
.views {
padding: calc(var(--padding) * 2);
display: flex;
flex-direction: column;
height: 100%;
}
@@ -0,0 +1,229 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import React from "react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Catalog } from "./catalog";
import type { CatalogEntityActionContext, CatalogEntityData } from "../../../common/catalog";
import { CatalogEntity } from "../../../common/catalog";
import type { CatalogEntityOnBeforeRun, CatalogEntityRegistry } from "../../api/catalog/entity/registry";
import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.store";
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import type { DiContainer } from "@ogre-tools/injectable";
import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable";
import catalogEntityRegistryInjectable from "../../api/catalog/entity/registry.injectable";
import type { DiRender } from "../test-utils/renderFor";
import { renderFor } from "../test-utils/renderFor";
import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import getConfigurationFileModelInjectable from "../../../common/get-configuration-file-model/get-configuration-file-model.injectable";
import type { AppEvent } from "../../../common/app-event-bus/event-bus";
import appEventBusInjectable from "../../../common/app-event-bus/app-event-bus.injectable";
import { computed } from "mobx";
import broadcastMessageInjectable from "../../../common/ipc/broadcast-message.injectable";
import type { AsyncFnMock } from "@async-fn/jest";
import asyncFn from "@async-fn/jest";
import { flushPromises } from "../../../common/test-utils/flush-promises";
import userStoreInjectable from "../../../common/user-store/user-store.injectable";
import releaseChannelInjectable from "../../../common/vars/release-channel.injectable";
import defaultUpdateChannelInjectable from "../../../features/application-update/common/selected-update-channel/default-update-channel.injectable";
import currentlyInClusterFrameInjectable from "../../routes/currently-in-cluster-frame.injectable";
class MockCatalogEntity extends CatalogEntity {
public apiVersion = "api";
public kind = "kind";
constructor(data: CatalogEntityData, public onRun: (context: CatalogEntityActionContext) => void | Promise<void>) {
super(data);
}
}
function createMockCatalogEntity(onRun: (context: CatalogEntityActionContext) => void | Promise<void>) {
return new MockCatalogEntity({
metadata: {
uid: "a_catalogEntity_uid",
name: "a catalog entity",
labels: {
test: "label",
},
},
status: {
phase: "",
},
spec: {},
}, onRun);
}
describe("<Catalog />", () => {
let di: DiContainer;
let catalogEntityStore: CatalogEntityStore;
let catalogEntityRegistry: CatalogEntityRegistry;
let appEventListener: jest.MockedFunction<(event: AppEvent) => void>;
let onRun: jest.MockedFunction<(context: CatalogEntityActionContext) => void | Promise<void>>;
let catalogEntityItem: MockCatalogEntity;
let render: DiRender;
beforeEach(async () => {
di = getDiForUnitTesting({ doGeneralOverrides: true });
di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data");
di.override(currentlyInClusterFrameInjectable, () => false);
di.override(broadcastMessageInjectable, () => async () => {});
di.permitSideEffects(getConfigurationFileModelInjectable);
render = renderFor(di);
onRun = jest.fn();
catalogEntityItem = createMockCatalogEntity(onRun);
catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable);
appEventListener = jest.fn();
di.inject(appEventBusInjectable).addListener(appEventListener);
catalogEntityStore = di.inject(catalogEntityStoreInjectable);
Object.assign(catalogEntityStore, {
selectedItem: computed(() => catalogEntityItem),
});
di.override(releaseChannelInjectable, () => ({
get: () => "latest" as const,
init: async () => {},
}));
await di.inject(defaultUpdateChannelInjectable).init();
di.inject(userStoreInjectable).load();
});
describe("can use catalogEntityRegistry.addOnBeforeRun to add hooks for catalog entities", () => {
let onBeforeRunMock: AsyncFnMock<CatalogEntityOnBeforeRun>;
beforeEach(() => {
onBeforeRunMock = asyncFn();
catalogEntityRegistry.addOnBeforeRun(onBeforeRunMock);
render(<Catalog />);
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
});
it("calls on before run event", () => {
const target = onBeforeRunMock.mock.calls[0][0].target;
const actual = { id: target.getId(), name: target.getName() };
expect(actual).toEqual({
id: "a_catalogEntity_uid",
name: "a catalog entity",
});
});
it("does not call onRun yet", () => {
expect(onRun).not.toHaveBeenCalled();
});
it("when before run event resolves, calls onRun", async () => {
await onBeforeRunMock.resolve();
expect(onRun).toHaveBeenCalled();
});
});
it("onBeforeRun prevents event => onRun wont be triggered", async () => {
const onBeforeRunMock = jest.fn((event) => event.preventDefault());
catalogEntityRegistry.addOnBeforeRun(onBeforeRunMock);
render(<Catalog />);
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
await flushPromises();
expect(onRun).not.toHaveBeenCalled();
});
it("addOnBeforeRun throw an exception => onRun will be triggered", async () => {
const onBeforeRunMock = jest.fn(() => {
throw new Error("some error");
});
catalogEntityRegistry.addOnBeforeRun(onBeforeRunMock);
render(<Catalog />);
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
await flushPromises();
expect(onRun).toHaveBeenCalled();
});
it("addOnRunHook return a promise and does not prevent run event => onRun()", (done) => {
onRun.mockImplementation(() => done());
catalogEntityRegistry.addOnBeforeRun(
async () => {
// no op
},
);
render(<Catalog />);
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
});
it("addOnRunHook return a promise and prevents event wont be triggered", async () => {
const onBeforeRunMock = asyncFn();
catalogEntityRegistry.addOnBeforeRun(onBeforeRunMock);
render(<Catalog />);
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
onBeforeRunMock.mock.calls[0][0].preventDefault();
await onBeforeRunMock.resolve();
expect(onRun).not.toHaveBeenCalled();
});
it("addOnRunHook return a promise and reject => onRun will be triggered", async () => {
const onBeforeRunMock = asyncFn();
catalogEntityRegistry.addOnBeforeRun(onBeforeRunMock);
render(<Catalog />);
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
await onBeforeRunMock.reject();
expect(onRun).toHaveBeenCalled();
});
it("emits catalog open AppEvent", () => {
render(<Catalog />);
expect(appEventListener).toHaveBeenCalledWith( {
action: "open",
name: "catalog",
});
});
it("emits catalog change AppEvent when changing the category", () => {
render(<Catalog />);
userEvent.click(screen.getByText("Web Links"));
expect(appEventListener).toHaveBeenCalledWith({
action: "change-category",
name: "catalog",
params: {
category: "Web Links",
},
});
});
});
@@ -0,0 +1,375 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./catalog.module.scss";
import React from "react";
import { disposeOnUnmount, observer } from "mobx-react";
import { ItemListLayout } from "../item-object-list";
import type { IComputedValue } from "mobx";
import { action, computed, makeObservable, observable, reaction, runInAction, when } from "mobx";
import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.store";
import { MenuItem, MenuActions } from "../menu";
import type { CatalogEntityContextMenu } from "../../api/catalog-entity";
import type { CatalogCategory, CatalogCategoryRegistry, CatalogEntity } from "../../../common/catalog";
import { CatalogAddButton } from "./catalog-add-button";
import type { ShowNotification } from "../notifications";
import { MainLayout } from "../layout/main-layout";
import type { StorageLayer } from "../../utils";
import { prevDefault } from "../../utils";
import { CatalogEntityDetails } from "./entity-details/view";
import { CatalogMenu } from "./catalog-menu";
import { RenderDelay } from "../render-delay/render-delay";
import { Icon } from "../icon";
import { HotbarToggleMenuItem } from "./hotbar-toggle-menu-item";
import { Avatar } from "../avatar";
import { withInjectables } from "@ogre-tools/injectable-react";
import catalogPreviousActiveTabStorageInjectable from "./catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable";
import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable";
import type { GetCategoryColumnsParams, CategoryColumns } from "./columns/get.injectable";
import getCategoryColumnsInjectable from "./columns/get.injectable";
import type { RegisteredCustomCategoryViewDecl } from "./custom-views.injectable";
import customCategoryViewsInjectable from "./custom-views.injectable";
import type { CustomCategoryViewComponents } from "./custom-views";
import type { NavigateToCatalog } from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";
import navigateToCatalogInjectable from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";
import catalogRouteParametersInjectable from "./catalog-route-parameters.injectable";
import { browseCatalogTab } from "./catalog-browse-tab";
import hotbarStoreInjectable from "../../../common/hotbars/store.injectable";
import type { HotbarStore } from "../../../common/hotbars/store";
import type { VisitEntityContextMenu } from "../../../common/catalog/visit-entity-context-menu.injectable";
import catalogCategoryRegistryInjectable from "../../../common/catalog/category-registry.injectable";
import visitEntityContextMenuInjectable from "../../../common/catalog/visit-entity-context-menu.injectable";
import type { Navigate } from "../../navigation/navigate.injectable";
import navigateInjectable from "../../navigation/navigate.injectable";
import type { NormalizeCatalogEntityContextMenu } from "../../catalog/normalize-menu-item.injectable";
import normalizeCatalogEntityContextMenuInjectable from "../../catalog/normalize-menu-item.injectable";
import type { EmitAppEvent } from "../../../common/app-event-bus/emit-event.injectable";
import emitAppEventInjectable from "../../../common/app-event-bus/emit-event.injectable";
import type { Logger } from "../../../common/logger";
import loggerInjectable from "../../../common/logger.injectable";
import showErrorNotificationInjectable from "../notifications/show-error-notification.injectable";
interface Dependencies {
catalogPreviousActiveTabStorage: StorageLayer<string | null>;
catalogEntityStore: CatalogEntityStore;
getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns;
customCategoryViews: IComputedValue<Map<string, Map<string, RegisteredCustomCategoryViewDecl>>>;
emitEvent: EmitAppEvent;
routeParameters: {
group: IComputedValue<string>;
kind: IComputedValue<string>;
};
navigateToCatalog: NavigateToCatalog;
hotbarStore: HotbarStore;
catalogCategoryRegistry: CatalogCategoryRegistry;
visitEntityContextMenu: VisitEntityContextMenu;
navigate: Navigate;
normalizeMenuItem: NormalizeCatalogEntityContextMenu;
showErrorNotification: ShowNotification;
logger: Logger;
}
@observer
class NonInjectedCatalog extends React.Component<Dependencies> {
private readonly menuItems = observable.array<CatalogEntityContextMenu>();
@observable activeTab: string | undefined = undefined;
constructor(props: Dependencies) {
super(props);
makeObservable(this);
}
@computed
get routeActiveTab(): string {
const { routeParameters: { group, kind }, catalogPreviousActiveTabStorage } = this.props;
const dereferencedGroup = group.get();
const dereferencedKind = kind.get();
if (dereferencedGroup && dereferencedKind) {
return `${dereferencedGroup}/${dereferencedKind}`;
}
return catalogPreviousActiveTabStorage.get() || browseCatalogTab;
}
async componentDidMount() {
const {
catalogEntityStore,
catalogPreviousActiveTabStorage,
catalogCategoryRegistry,
logger,
showErrorNotification,
} = this.props;
disposeOnUnmount(this, [
catalogEntityStore.watch(),
reaction(() => this.routeActiveTab, async (routeTab) => {
catalogPreviousActiveTabStorage.set(this.routeActiveTab);
try {
if (routeTab !== browseCatalogTab) {
// we need to wait because extensions might take a while to load
await when(() => Boolean(catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab)), { timeout: 5_000 });
}
const item = catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab);
runInAction(() => {
this.activeTab = routeTab;
catalogEntityStore.activeCategory.set(item);
});
} catch (error) {
logger.warn("Failed to find route tab", error);
showErrorNotification((
<p>
{"Unknown category: "}
{routeTab}
</p>
));
}
}, { fireImmediately: true }),
// If active category is filtered out, automatically switch to the first category
reaction(() => catalogCategoryRegistry.filteredItems, () => {
if (!catalogCategoryRegistry.filteredItems.find(item => item.getId() === catalogEntityStore.activeCategory.get()?.getId())) {
const item = catalogCategoryRegistry.filteredItems[0];
runInAction(() => {
if (item) {
this.activeTab = item.getId();
this.props.catalogEntityStore.activeCategory.set(item);
}
});
}
}),
]);
this.props.emitEvent({
name: "catalog",
action: "open",
});
}
addToHotbar(entity: CatalogEntity): void {
this.props.hotbarStore.addToHotbar(entity);
}
removeFromHotbar(entity: CatalogEntity): void {
this.props.hotbarStore.removeFromHotbar(entity.getId());
}
onDetails = (entity: CatalogEntity) => {
if (this.props.catalogEntityStore.selectedItemId.get()) {
this.props.catalogEntityStore.selectedItemId.set(undefined);
} else {
this.props.catalogEntityStore.onRun(entity);
}
};
get categories() {
return this.props.catalogCategoryRegistry.items;
}
onTabChange = action((tabId: string | null) => {
const activeCategory = this.categories.find(category => category.getId() === tabId);
this.props.emitEvent({
name: "catalog",
action: "change-category",
params: {
category: activeCategory ? activeCategory.getName() : "Browse",
},
});
if (activeCategory) {
this.props.catalogPreviousActiveTabStorage.set(`${activeCategory.spec.group}/${activeCategory.spec.names.kind}`);
this.props.navigateToCatalog({ group: activeCategory.spec.group, kind: activeCategory.spec.names.kind });
} else {
this.props.catalogPreviousActiveTabStorage.set(null);
this.props.navigateToCatalog({ group: browseCatalogTab });
}
});
renderItemMenu = (entity: CatalogEntity) => {
const onOpen = () => {
this.menuItems.clear();
this.props.visitEntityContextMenu(entity, {
menuItems: this.menuItems,
navigate: this.props.navigate,
});
};
return (
<MenuActions
id={`menu-actions-for-catalog-for-${entity.getId()}`}
data-testid={`menu-actions-for-catalog-for-${entity.getId()}`}
onOpen={onOpen}
>
<MenuItem
key="open-details"
data-testid={`open-details-menu-item-for-${entity.getId()}`}
onClick={() => this.props.catalogEntityStore.selectedItemId.set(entity.getId())}
>
View Details
</MenuItem>
{
this.menuItems
.map(this.props.normalizeMenuItem)
.map((menuItem, index) => (
<MenuItem key={index} onClick={menuItem.onClick}>
{menuItem.title}
</MenuItem>
))
}
<HotbarToggleMenuItem
key="hotbar-toggle"
entity={entity}
addContent="Add to Hotbar"
removeContent="Remove from Hotbar"
/>
</MenuActions>
);
};
renderName(entity: CatalogEntity) {
const isItemInHotbar = this.props.hotbarStore.isAddedToActive(entity);
return (
<>
<Avatar
title={entity.getName()}
colorHash={`${entity.getName()}-${entity.getSource()}`}
src={entity.spec.icon?.src}
background={entity.spec.icon?.background}
className={styles.catalogAvatar}
size={24}
>
{entity.spec.icon?.material && <Icon material={entity.spec.icon?.material} small/>}
</Avatar>
<span>{entity.getName()}</span>
<Icon
small
className={styles.pinIcon}
svg={isItemInHotbar ? "push_off" : "push_pin"}
tooltip={isItemInHotbar ? "Remove from Hotbar" : "Add to Hotbar"}
onClick={prevDefault(() => isItemInHotbar ? this.removeFromHotbar(entity) : this.addToHotbar(entity))}
/>
</>
);
}
renderViews = (activeCategory: CatalogCategory | undefined) => {
if (!activeCategory) {
return this.renderList(undefined);
}
const customViews = this.props.customCategoryViews.get()
.get(activeCategory.spec.group)
?.get(activeCategory.spec.names.kind);
const renderView = ({ View }: CustomCategoryViewComponents, index: number) => (
<View
key={index}
category={activeCategory}
/>
);
return (
<>
{customViews?.before.map(renderView)}
{this.renderList(activeCategory)}
{customViews?.after.map(renderView)}
</>
);
};
renderList(activeCategory: CatalogCategory | undefined) {
const { catalogEntityStore, getCategoryColumns } = this.props;
const tableId = activeCategory
? `catalog-items-${activeCategory.metadata.name.replace(" ", "")}`
: "catalog-items";
if (this.activeTab === undefined) {
return null;
}
return (
<ItemListLayout<CatalogEntity, false>
className={styles.Catalog}
tableId={tableId}
renderHeaderTitle={activeCategory?.metadata.name ?? "Browse All"}
isSelectable={false}
isConfigurable={true}
preloadStores={false}
store={catalogEntityStore}
getItems={() => catalogEntityStore.entities.get()}
customizeTableRowProps={entity => ({
disabled: !entity.isEnabled(),
})}
{...getCategoryColumns({ activeCategory })}
onDetails={this.onDetails}
renderItemMenu={this.renderItemMenu}
data-testid={`catalog-list-for-${activeCategory?.metadata.name ?? "browse-all"}`}
/>
);
}
render() {
const activeCategory = this.props.catalogEntityStore.activeCategory.get();
const selectedItem = this.props.catalogEntityStore.selectedItem.get();
return (
<MainLayout
sidebar={(
<CatalogMenu
activeTab={this.activeTab}
onItemClick={this.onTabChange}
/>
)}
>
<div className={styles.views}>
{this.renderViews(activeCategory)}
</div>
{
selectedItem
? (
<CatalogEntityDetails
entity={selectedItem}
hideDetails={() => this.props.catalogEntityStore.selectedItemId.set(undefined)}
onRun={() => this.props.catalogEntityStore.onRun(selectedItem)}
/>
)
: activeCategory
? (
<RenderDelay>
<CatalogAddButton category={activeCategory} />
</RenderDelay>
)
: null
}
</MainLayout>
);
}
}
export const Catalog = withInjectables<Dependencies>(NonInjectedCatalog, {
getProps: (di, props) => ({
...props,
catalogEntityStore: di.inject(catalogEntityStoreInjectable),
catalogPreviousActiveTabStorage: di.inject(catalogPreviousActiveTabStorageInjectable),
getCategoryColumns: di.inject(getCategoryColumnsInjectable),
customCategoryViews: di.inject(customCategoryViewsInjectable),
routeParameters: di.inject(catalogRouteParametersInjectable),
navigateToCatalog: di.inject(navigateToCatalogInjectable),
emitEvent: di.inject(emitAppEventInjectable),
hotbarStore: di.inject(hotbarStoreInjectable),
catalogCategoryRegistry: di.inject(catalogCategoryRegistryInjectable),
visitEntityContextMenu: di.inject(visitEntityContextMenuInjectable),
navigate: di.inject(navigateInjectable),
normalizeMenuItem: di.inject(normalizeCatalogEntityContextMenuInjectable),
logger: di.inject(loggerInjectable),
showErrorNotification: di.inject(showErrorNotificationInjectable),
}),
});
@@ -0,0 +1,35 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns";
import { getInjectable } from "@ogre-tools/injectable";
import namedCategoryColumnInjectable from "./named-category.injectable";
import defaultCategoryColumnsInjectable from "./default-category.injectable";
const defaultBrowseAllColumns: RegisteredAdditionalCategoryColumn[] = [
{
id: "kind",
priority: 5,
renderCell: entity => entity.kind,
titleProps: {
id: "kind",
sortBy: "kind",
title: "Kind",
},
sortCallback: entity => entity.kind,
},
];
const browseAllColumnsInjectable = getInjectable({
id: "browse-all-columns",
instantiate: (di) => [
...defaultBrowseAllColumns,
di.inject(namedCategoryColumnInjectable),
...di.inject(defaultCategoryColumnsInjectable),
],
});
export default browseAllColumnsInjectable;
@@ -0,0 +1,17 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectionToken } from "@ogre-tools/injectable";
import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns";
export interface CustomCatalogCategoryRegistration {
kind: string;
group: string;
registration: RegisteredAdditionalCategoryColumn;
}
export const customCatalogCategoryColumnInjectionToken = getInjectionToken<CustomCatalogCategoryRegistration>({
id: "custom-catalog-category-column-token",
});
@@ -0,0 +1,64 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "../catalog.module.scss";
import React from "react";
import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns";
import { KubeObject } from "../../../../common/k8s-api/kube-object";
import { getInjectable } from "@ogre-tools/injectable";
import getLabelBadgesInjectable from "../get-label-badges.injectable";
const defaultCategoryColumnsInjectable = getInjectable({
id: "default-category-columns",
instantiate: (di): RegisteredAdditionalCategoryColumn[] => {
const getLabelBadges = di.inject(getLabelBadgesInjectable);
return [
{
id: "source",
priority: 10,
renderCell: entity => entity.getSource(),
titleProps: {
title: "Source",
className: styles.sourceCell,
id: "source",
sortBy: "source",
},
sortCallback: entity => entity.getSource(),
searchFilter: entity => `source=${entity.getSource()}`,
},
{
id: "labels",
priority: 20,
renderCell: getLabelBadges,
titleProps: {
id: "labels",
title: "Labels",
className: `${styles.labelsCell} scrollable`,
},
searchFilter: entity => KubeObject.stringifyLabels(entity.metadata.labels),
},
{
id: "status",
priority: 30,
renderCell: entity => (
<span key="phase" className={entity.status.phase}>
{entity.status.phase}
</span>
),
titleProps: {
title: "Status",
className: styles.statusCell,
id: "status",
sortBy: "status",
},
searchFilter: entity => entity.status.phase,
sortCallback: entity => entity.status.phase,
},
];
},
});
export default defaultCategoryColumnsInjectable;
@@ -0,0 +1,55 @@
/**
* 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 { extensionRegistratorInjectionToken } from "../../../../extensions/extension-loader/extension-registrator-injection-token";
import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
import type { AdditionalCategoryColumnRegistration } from "../custom-category-columns";
import { customCatalogCategoryColumnInjectionToken } from "./custom-token";
const customCategoryColumnsRegistratorInjectable = getInjectable({
id: "custom-category-columns-registrator",
instantiate: () => (ext) => {
const extension = ext as LensRendererExtension;
return extension.additionalCategoryColumns.map(getInjectableForColumnRegistrationFor(extension));
},
injectionToken: extensionRegistratorInjectionToken,
});
export default customCategoryColumnsRegistratorInjectable;
const getInjectableForColumnRegistrationFor = (extension: LensRendererExtension) => ({
group,
id,
kind,
renderCell,
titleProps,
priority = 50,
searchFilter,
sortCallback,
}: AdditionalCategoryColumnRegistration) => {
return getInjectable({
id: `${extension.manifest.name}:${group}/${kind}:${id}`,
instantiate: () => ({
group,
kind,
registration: {
renderCell,
priority,
id,
titleProps: {
id,
...titleProps,
sortBy: sortCallback
? id
: undefined,
},
searchFilter,
sortCallback,
},
}),
injectionToken: customCatalogCategoryColumnInjectionToken,
});
};
@@ -0,0 +1,66 @@
/**
* 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 { orderBy } from "lodash";
import type { CatalogCategory, CatalogEntity } from "../../../../common/catalog";
import type { ItemListLayoutProps } from "../../item-object-list";
import browseAllColumnsInjectable from "./browse-all.injectable";
import getColumnsForCategoryInjectable from "./specific-category.injectable";
export interface GetCategoryColumnsParams {
activeCategory: CatalogCategory | null | undefined;
}
export type CategoryColumns = Required<Pick<ItemListLayoutProps<CatalogEntity>, "sortingCallbacks" | "searchFilters" | "renderTableContents" | "renderTableHeader">>;
export type GetCategoryColumns = (params: GetCategoryColumnsParams) => CategoryColumns;
const getCategoryColumnsInjectable = getInjectable({
id: "get-category-columns",
instantiate: (di): GetCategoryColumns => {
const getColumnsForCategory = di.inject(getColumnsForCategoryInjectable);
const browseAllColumns = di.inject(browseAllColumnsInjectable);
return ({ activeCategory }) => {
const allRegistrations = orderBy(
activeCategory
? getColumnsForCategory(activeCategory)
: browseAllColumns,
"priority",
"asc",
);
const sortingCallbacks: CategoryColumns["sortingCallbacks"] = {};
const searchFilters: CategoryColumns["searchFilters"] = [];
const renderTableHeader: CategoryColumns["renderTableHeader"] = [];
const tableRowRenderers: ((entity: CatalogEntity) => React.ReactNode)[] = [];
for (const registration of allRegistrations) {
if (registration.sortCallback) {
sortingCallbacks[registration.id] = registration.sortCallback;
}
if (registration.searchFilter) {
searchFilters.push(registration.searchFilter);
}
tableRowRenderers.push(registration.renderCell);
renderTableHeader.push({
id: registration.id,
...registration.titleProps,
});
}
return {
sortingCallbacks,
renderTableHeader,
renderTableContents: entity => tableRowRenderers.map(fn => fn(entity)),
searchFilters,
};
};
},
});
export default getCategoryColumnsInjectable;
@@ -0,0 +1,35 @@
/**
* 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 React from "react";
import type { KubernetesCluster } from "../../../../common/catalog-entities";
import { customCatalogCategoryColumnInjectionToken } from "./custom-token";
const kubernetesApiVersionColumnInjectable = getInjectable({
id: "kubernetes-api-version-column",
instantiate: () => ({
group: "entity.k8slens.dev",
kind: "KubernetesCluster",
registration: {
id: "version",
priority: 30,
renderCell: entity => {
const k8sVersion = (entity as KubernetesCluster).metadata.kubeVersion;
return (
<span key="version">
{k8sVersion === "unknown" ? "" : k8sVersion}
</span>
);
},
titleProps: {
title: "Version",
},
},
}),
injectionToken: customCatalogCategoryColumnInjectionToken,
});
export default kubernetesApiVersionColumnInjectable;
@@ -0,0 +1,36 @@
/**
* 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 React from "react";
import type { KubernetesCluster } from "../../../../common/catalog-entities";
import { customCatalogCategoryColumnInjectionToken } from "./custom-token";
const kubernetesDistributionColumnInjectable = getInjectable({
id: "kubernetes-distribution-column",
instantiate: () => ({
group: "entity.k8slens.dev",
kind: "KubernetesCluster",
registration: {
id: "distro",
priority: 30,
renderCell: entity => {
const k8sDistro = (entity as KubernetesCluster).metadata.distro;
return (
<span key="distro">
{k8sDistro === "unknown" ? "" : k8sDistro}
</span>
);
},
titleProps: {
title: "Distro",
},
},
}),
injectionToken: customCatalogCategoryColumnInjectionToken,
});
export default kubernetesDistributionColumnInjectable;
@@ -0,0 +1,65 @@
/**
* 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 styles from "../catalog.module.scss";
import type { CatalogEntity } from "../../../../common/catalog";
import { prevDefault } from "../../../utils";
import { Avatar } from "../../avatar";
import { Icon } from "../../icon";
import React from "react";
import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns";
import hotbarStoreInjectable from "../../../../common/hotbars/store.injectable";
import type { HotbarStore } from "../../../../common/hotbars/store";
const renderEntityName = (hotbarStore: HotbarStore) => (entity: CatalogEntity) => {
const isItemInHotbar = hotbarStore.isAddedToActive(entity);
const onClick = prevDefault(
isItemInHotbar
? () => hotbarStore.removeFromHotbar(entity.getId())
: () => hotbarStore.addToHotbar(entity),
);
return (
<>
<Avatar
title={entity.getName()}
colorHash={`${entity.getName()}-${entity.getSource()}`}
src={entity.spec.icon?.src}
background={entity.spec.icon?.background}
className={styles.catalogAvatar}
size={24}
>
{entity.spec.icon?.material && <Icon material={entity.spec.icon?.material} small/>}
</Avatar>
<span>{entity.getName()}</span>
<Icon
small
className={styles.pinIcon}
svg={isItemInHotbar ? "push_off" : "push_pin"}
tooltip={isItemInHotbar ? "Remove from Hotbar" : "Add to Hotbar"}
onClick={onClick}
/>
</>
);
};
const namedCategoryColumnInjectable = getInjectable({
id: "name-category-column",
instantiate: (di): RegisteredAdditionalCategoryColumn => ({
id: "name",
priority: 0,
renderCell: renderEntityName(di.inject(hotbarStoreInjectable)),
titleProps: {
title: "Name",
className: styles.entityName,
id: "name",
sortBy: "name",
},
searchFilter: (entity) => entity.getName(),
sortCallback: (entity) => `name=${entity.getName()}`,
}),
});
export default namedCategoryColumnInjectable;
@@ -0,0 +1,43 @@
/**
* 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 { CatalogCategory } from "../../../api/catalog-entity";
import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns";
import categoryColumnsInjectable from "../custom-category-columns.injectable";
import defaultCategoryColumnsInjectable from "./default-category.injectable";
import namedCategoryColumnInjectable from "./named-category.injectable";
export type GetColumnsForCategory = (activeCategory: CatalogCategory) => RegisteredAdditionalCategoryColumn[];
const getColumnsForCategoryInjectable = getInjectable({
id: "get-columns-for-category",
instantiate: (di): GetColumnsForCategory => {
const extensionColumns = di.inject(categoryColumnsInjectable);
const defaultCategoryColumns = di.inject(defaultCategoryColumnsInjectable);
const nameCategoryColumn = di.inject(namedCategoryColumnInjectable);
return (activeCategory) => {
const fromExtensions = (
extensionColumns
.get()
.get(activeCategory.spec.group)
?.get(activeCategory.spec.names.kind)
?? []
);
const fromCategory = activeCategory.spec.displayColumns?.map(({ priority = 50, ...column }) => ({
priority,
...column,
})) ?? defaultCategoryColumns;
return [
nameCategoryColumn,
...fromExtensions,
...fromCategory,
];
};
},
});
export default getColumnsForCategoryInjectable;
@@ -0,0 +1,33 @@
/**
* 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 { computedInjectManyInjectable } from "@ogre-tools/injectable-extension-for-mobx";
import { computed } from "mobx";
import { getOrInsert, getOrInsertMap } from "../../utils";
import { customCatalogCategoryColumnInjectionToken } from "./columns/custom-token";
import type { RegisteredAdditionalCategoryColumn } from "./custom-category-columns";
const categoryColumnsInjectable = getInjectable({
id: "category-columns",
instantiate: (di) => {
const computedInjectMany = di.inject(computedInjectManyInjectable);
const columnRegistrations = computedInjectMany(customCatalogCategoryColumnInjectionToken);
return computed(() => {
const res = new Map<string, Map<string, RegisteredAdditionalCategoryColumn[]>>();
for (const { group, kind, registration } of columnRegistrations.get()) {
const byGroup = getOrInsertMap(res, group);
const byKind = getOrInsert(byGroup, kind, []);
byKind.push(registration);
}
return res;
});
},
});
export default categoryColumnsInjectable;
@@ -0,0 +1,85 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type React from "react";
import type { CatalogEntity } from "../../../common/catalog";
import type { TableCellProps } from "../table";
/**
* These are the supported props for the title cell
*/
export interface TitleCellProps {
className?: string;
title: string;
}
export interface CategoryColumnRegistration {
/**
* The sorting order value.
*
* @default 50
*/
priority?: number;
/**
* This value MUST to be unique to your extension
*/
id: string;
/**
* This function will be called to generate the cells (on demand) for the column
*/
renderCell: (entity: CatalogEntity) => React.ReactNode;
/**
* This function will be used to generate the columns title cell.
*/
titleProps: TitleCellProps;
/**
* If provided then the column will support sorting and this function will be called to
* determine a row's ordering.
*
* strings are sorted ahead of numbers, and arrays determine ordering between equal
* elements of the previous index.
*/
sortCallback?: (entity: CatalogEntity) => string | number | (string | number)[];
/**
* If provided then searching is supported on this column and this function will be called
* to determine if the current search string matches for this row.
*/
searchFilter?: (entity: CatalogEntity) => string | string[];
}
/**
* This is the type used to declare new catalog category columns
*/
export interface AdditionalCategoryColumnRegistration extends CategoryColumnRegistration {
/**
* The catalog entity kind that is declared by the category for this registration
*
* e.g.
* - `"KubernetesCluster"`
*/
kind: string;
/**
* The catalog entity group that is declared by the category for this registration
*
* e.g.
* - `"entity.k8slens.dev"`
*/
group: string;
}
export interface RegisteredAdditionalCategoryColumn {
id: string;
priority: number;
renderCell: (entity: CatalogEntity) => React.ReactNode;
titleProps: TableCellProps;
sortCallback?: (entity: CatalogEntity) => string | number | (string | number)[];
searchFilter?: (entity: CatalogEntity) => string | string[];
}
@@ -0,0 +1,60 @@
/**
* 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 { orderBy } from "lodash";
import type { IComputedValue } from "mobx";
import { computed } from "mobx";
import type { LensRendererExtension } from "../../../extensions/lens-renderer-extension";
import rendererExtensionsInjectable from "../../../extensions/renderer-extensions.injectable";
import { getOrInsert, getOrInsertMap } from "../../utils";
import type { CustomCategoryViewComponents } from "./custom-views";
interface Dependencies {
extensions: IComputedValue<LensRendererExtension[]>;
}
export interface RegisteredCustomCategoryViewDecl {
/**
* The asc sorted list of items with priority set to < 50
*/
before: CustomCategoryViewComponents[];
/**
* The asc sorted list of items with priority not set or set to >= 50
*/
after: CustomCategoryViewComponents[];
}
function getCustomCategoryViews({ extensions }: Dependencies): IComputedValue<Map<string, Map<string, RegisteredCustomCategoryViewDecl>>> {
return computed(() => {
const res = new Map<string, Map<string, RegisteredCustomCategoryViewDecl>>();
const registrations = extensions.get()
.flatMap(ext => ext.customCategoryViews)
.map(({ priority = 50, ...rest }) => ({ priority, ...rest }));
const sortedRegistrations = orderBy(registrations, "priority", "asc");
for (const { priority, group, kind, components } of sortedRegistrations) {
const byGroup = getOrInsertMap(res, group);
const { before, after } = getOrInsert(byGroup, kind, { before: [], after: [] });
if (priority < 50) {
before.push(components);
} else {
after.push(components);
}
}
return res;
});
}
const customCategoryViewsInjectable = getInjectable({
id: "custom-category-views",
instantiate: (di) => getCustomCategoryViews({
extensions: di.inject(rendererExtensionsInjectable),
}),
});
export default customCategoryViewsInjectable;
@@ -0,0 +1,57 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type React from "react";
import type { CatalogCategory } from "../../api/catalog-entity";
/**
* The props for CustomCategoryViewComponents.View
*/
export interface CustomCategoryViewProps {
/**
* The category instance itself
*/
category: CatalogCategory;
}
/**
* The components for the category view.
*/
export interface CustomCategoryViewComponents {
View: React.ComponentType<CustomCategoryViewProps>;
}
/**
* This is the type used to declare additional views for a specific category
*/
export interface CustomCategoryViewRegistration {
/**
* The catalog entity kind that is declared by the category for this registration
*
* e.g.
* - `"KubernetesCluster"`
*/
kind: string;
/**
* The catalog entity group that is declared by the category for this registration
*
* e.g.
* - `"entity.k8slens.dev"`
*/
group: string;
/**
* The sorting order value. Used to determine the total order of the views.
*
* @default 50
*/
priority?: number;
/**
* The components for this registration
*/
components: CustomCategoryViewComponents;
}
@@ -0,0 +1,33 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { computedInjectManyInjectable } from "@ogre-tools/injectable-extension-for-mobx";
import { computed } from "mobx";
import { byOrderNumber } from "../../../../common/utils/composable-responsibilities/orderable/orderable";
import type { CatalogEntity } from "../../../api/catalog-entity";
import { catalogEntityDetailItemInjectionToken } from "./token";
const catalogEntityDetailItemsInjectable = getInjectable({
id: "catalog-entity-detail-items",
instantiate: (di, entity) => {
const computedInjectMany = di.inject(computedInjectManyInjectable);
const detailItems = computedInjectMany(catalogEntityDetailItemInjectionToken);
return computed(() => (
detailItems.get()
.filter(item => (
item.apiVersions.has(entity.apiVersion)
&& item.kind === entity.kind
))
.sort(byOrderNumber)
.map(item => item.components.Details)
));
},
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, entity: CatalogEntity) => `${entity.apiVersion}/${entity.kind}`,
}),
});
export default catalogEntityDetailItemsInjectable;
@@ -0,0 +1,39 @@
/**
* 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 React from "react";
import { KubernetesCluster } from "../../../../../common/catalog-entities";
import { DrawerTitle, DrawerItem } from "../../../drawer";
import { catalogEntityDetailItemInjectionToken } from "../token";
const kubernetesClusterDetailsItemInjectable = getInjectable({
id: "kubernetes-cluster-details-item",
instantiate: () => ({
apiVersions: new Set([KubernetesCluster.apiVersion]),
kind: KubernetesCluster.kind,
orderNumber: 40,
components: {
Details: ({ entity }) => (
<>
<DrawerTitle>Kubernetes Information</DrawerTitle>
<div className="box grow EntityMetadata">
<DrawerItem
name="Distribution"
data-testid={`kubernetes-distro-for-${entity.getId()}`}
>
{entity.metadata.distro || "unknown"}
</DrawerItem>
<DrawerItem name="Kubelet Version">
{entity.metadata.kubeVersion || "unknown"}
</DrawerItem>
</div>
</>
),
},
}),
injectionToken: catalogEntityDetailItemInjectionToken,
});
export default kubernetesClusterDetailsItemInjectable;
@@ -0,0 +1,34 @@
/**
* 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 React from "react";
import { WebLink } from "../../../../../common/catalog-entities";
import { DrawerTitle, DrawerItem } from "../../../drawer";
import { catalogEntityDetailItemInjectionToken } from "../token";
const weblinkDetailsItemInjectable = getInjectable({
id: "weblink-details-item",
instantiate: () => ({
apiVersions: new Set([WebLink.apiVersion]),
kind: WebLink.kind,
components: {
Details: ({ entity }) => (
<>
<DrawerTitle>More Information</DrawerTitle>
<DrawerItem
name="URL"
data-testid={`weblink-url-for-${entity.getId()}`}
>
{entity.spec.url}
</DrawerItem>
</>
),
},
orderNumber: 40,
}),
injectionToken: catalogEntityDetailItemInjectionToken,
});
export default weblinkDetailsItemInjectable;
@@ -0,0 +1,39 @@
/**
* 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 { extensionRegistratorInjectionToken } from "../../../../extensions/extension-loader/extension-registrator-injection-token";
import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
import type { CatalogEntity } from "../../../api/catalog-entity";
import { getRandId } from "../../../utils";
import type { CatalogEntityDetailRegistration } from "./token";
import { catalogEntityDetailItemInjectionToken } from "./token";
const catalogEntityDetailItemsRegistratorInjectable = getInjectable({
id: "catalog-entity-detail-items-registrator",
instantiate: () => (ext) => {
const extension = ext as LensRendererExtension;
return extension.catalogEntityDetailItems.map(getRegistratorFor(extension));
},
injectionToken: extensionRegistratorInjectionToken,
});
export default catalogEntityDetailItemsRegistratorInjectable;
const getRegistratorFor = (extension: LensRendererExtension) => ({
apiVersions,
components,
kind,
priority,
}: CatalogEntityDetailRegistration<CatalogEntity>) => getInjectable({
id: `catalog-entity-detail-item-for-${extension.sanitizedExtensionId}-${getRandId({ sep: "-" })}`,
instantiate: () => ({
apiVersions: new Set(apiVersions),
components,
kind,
orderNumber: priority ?? 50,
}),
injectionToken: catalogEntityDetailItemInjectionToken,
});
@@ -0,0 +1,35 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectionToken } from "@ogre-tools/injectable";
import type { CatalogEntity } from "../../../api/catalog-entity";
export interface CatalogEntityDetailsProps<T extends CatalogEntity> {
entity: T;
}
export type CatalogEntityDetailsComponent<T extends CatalogEntity> = React.ComponentType<CatalogEntityDetailsProps<T>>;
export interface CatalogEntityDetailComponents<T extends CatalogEntity> {
Details: CatalogEntityDetailsComponent<T>;
}
export interface CatalogEntityDetailRegistration<T extends CatalogEntity> {
kind: string;
apiVersions: string[];
components: CatalogEntityDetailComponents<T>;
priority?: number;
}
export interface CatalogEntityDetailItem {
kind: string;
apiVersions: Set<string>;
components: CatalogEntityDetailComponents<CatalogEntity>;
orderNumber: number;
}
export const catalogEntityDetailItemInjectionToken = getInjectionToken<CatalogEntityDetailItem>({
id: "catalog-entity-detail-item-token",
});
@@ -0,0 +1,33 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.entityDetails {
/* Move Details panel under TopBar line */
height: calc(100% - var(--main-layout-header));
margin-top: var(--main-layout-header);
}
.metadata {
margin-right: var(--margin);
}
.entityIcon {
margin-right: calc(var(--margin) * 3);
.avatar {
font-size: 3ch;
cursor: pointer;
}
.hint {
text-align: center;
font-size: var(--font-size-small);
text-transform: uppercase;
margin-top: var(--margin);
cursor: default;
user-select: none;
opacity: 0.5;
}
}
@@ -0,0 +1,127 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./view.module.scss";
import React, { Component } from "react";
import { observer } from "mobx-react";
import { Drawer, DrawerItem } from "../../drawer";
import type { CatalogCategory, CatalogEntity } from "../../../../common/catalog";
import { Icon } from "../../icon";
import { CatalogEntityDrawerMenu } from "../catalog-entity-drawer-menu";
import { cssNames } from "../../../utils";
import { Avatar } from "../../avatar";
import type { GetLabelBadges } from "../get-label-badges.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import getLabelBadgesInjectable from "../get-label-badges.injectable";
import isDevelopmentInjectable from "../../../../common/vars/is-development.injectable";
import type { IComputedValue } from "mobx";
import type { CatalogEntityDetailsComponent } from "./token";
import catalogEntityDetailItemsInjectable from "./detail-items.injectable";
export interface CatalogEntityDetailsProps<Entity extends CatalogEntity> {
entity: Entity;
hideDetails(): void;
onRun: () => void;
}
interface Dependencies {
getLabelBadges: GetLabelBadges;
isDevelopment: boolean;
detailItems: IComputedValue<CatalogEntityDetailsComponent<CatalogEntity>[]>;
}
@observer
class NonInjectedCatalogEntityDetails<Entity extends CatalogEntity> extends Component<CatalogEntityDetailsProps<Entity> & Dependencies> {
categoryIcon(category: CatalogCategory) {
if (Icon.isSvg(category.metadata.icon)) {
return <Icon svg={category.metadata.icon} smallest />;
} else {
return <Icon material={category.metadata.icon} smallest />;
}
}
renderContent(entity: Entity) {
const { onRun, hideDetails, getLabelBadges, isDevelopment, detailItems } = this.props;
const details = detailItems.get().map((Details, index) => <Details entity={entity} key={index} />);
return (
<>
<div className="flex" data-testid={`catalog-entity-details-content-for-${entity.getId()}`}>
<div className={styles.entityIcon}>
<Avatar
title={entity.getName()}
colorHash={`${entity.getName()}-${entity.getSource()}`}
size={128}
src={entity.spec.icon?.src}
data-testid="detail-panel-hot-bar-icon"
background={entity.spec.icon?.background}
onClick={onRun}
className={styles.avatar}
>
{entity.spec.icon?.material && <Icon material={entity.spec.icon?.material}/>}
</Avatar>
{entity.isEnabled() && (
<div className={styles.hint}>
Click to open
</div>
)}
</div>
<div className={cssNames("box grow", styles.metadata)}>
<DrawerItem name="Name">
{entity.getName()}
</DrawerItem>
<DrawerItem name="Kind">
{entity.kind}
</DrawerItem>
<DrawerItem name="Source">
{entity.getSource()}
</DrawerItem>
<DrawerItem name="Status">
{entity.status.phase}
</DrawerItem>
<DrawerItem name="Labels">
{getLabelBadges(entity, hideDetails)}
</DrawerItem>
{isDevelopment && (
<DrawerItem name="Id">
{entity.getId()}
</DrawerItem>
)}
</div>
</div>
<div className="box grow">
{details}
</div>
</>
);
}
render() {
const { entity, hideDetails } = this.props;
return (
<Drawer
className={styles.entityDetails}
usePortal={true}
open={true}
title={`${entity.kind}: ${entity.getName()}`}
toolbar={<CatalogEntityDrawerMenu entity={entity} key={entity.getId()} />}
onClose={hideDetails}
data-testid="catalog-entity-details-drawer"
>
{this.renderContent(entity)}
</Drawer>
);
}
}
export const CatalogEntityDetails = withInjectables<Dependencies, CatalogEntityDetailsProps<CatalogEntity>>(NonInjectedCatalogEntityDetails, {
getProps: (di, props) => ({
...props,
getLabelBadges: di.inject(getLabelBadgesInjectable),
isDevelopment: di.inject(isDevelopmentInjectable),
detailItems: di.inject(catalogEntityDetailItemsInjectable, props.entity),
}),
}) as <Entity extends CatalogEntity>(props: CatalogEntityDetailsProps<Entity>) => React.ReactElement;
@@ -0,0 +1,41 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./catalog.module.scss";
import React from "react";
import { getInjectable } from "@ogre-tools/injectable";
import type { CatalogEntity } from "../../api/catalog-entity";
import searchUrlPageParamInjectable from "../input/search-url-page-param.injectable";
import { KubeObject } from "../../../common/k8s-api/kube-object";
import { Badge } from "../badge";
export type GetLabelBadges = (entity: CatalogEntity, onClick?: (evt: React.MouseEvent<any, MouseEvent>) => void) => JSX.Element[];
const getLabelBadgesInjectable = getInjectable({
id: "get-label-badges",
instantiate: (di): GetLabelBadges => {
const searchUrlParam = di.inject(searchUrlPageParamInjectable);
return (entity, onClick) => (
KubeObject.stringifyLabels(entity.metadata.labels)
.map(label => (
<Badge
scrollable
className={styles.badge}
key={label}
label={label}
title={label}
onClick={(event) => {
searchUrlParam.set(label);
onClick?.(event);
event.stopPropagation();
}}
expandable={false}
/>
))
);
},
});
export default getLabelBadgesInjectable;
@@ -0,0 +1,60 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { ReactNode } from "react";
import React, { useState } from "react";
import { MenuItem } from "../menu";
import type { CatalogEntity } from "../../api/catalog-entity";
import { withInjectables } from "@ogre-tools/injectable-react";
import hotbarStoreInjectable from "../../../common/hotbars/store.injectable";
import type { HotbarStore } from "../../../common/hotbars/store";
interface Dependencies {
hotbarStore: HotbarStore;
}
interface HotbarToggleMenuItemProps {
entity: CatalogEntity;
addContent: ReactNode;
removeContent: ReactNode;
}
function NonInjectedHotbarToggleMenuItem({
addContent,
entity,
hotbarStore,
removeContent,
}: Dependencies & HotbarToggleMenuItemProps) {
const [itemInHotbar, setItemInHotbar] = useState(hotbarStore.isAddedToActive(entity));
return (
<MenuItem
onClick={() => {
if (itemInHotbar) {
hotbarStore.removeFromHotbar(entity.getId());
setItemInHotbar(false);
} else {
hotbarStore.addToHotbar(entity);
setItemInHotbar(true);
}
}}
>
{itemInHotbar ? removeContent : addContent }
</MenuItem>
);
}
export const HotbarToggleMenuItem = withInjectables<Dependencies, HotbarToggleMenuItemProps>(
NonInjectedHotbarToggleMenuItem,
{
getProps: (di, props) => ({
hotbarStore: di.inject(hotbarStoreInjectable),
...props,
}),
},
);
@@ -0,0 +1,6 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export * from "./catalog";
@@ -0,0 +1,58 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.ClusterIssues {
min-height: 350px;
position: relative;
grid-area: issues;
padding: calc(var(--margin) * 2) 0;
background: var(--contentColor);
&:global(.OnlyClusterIssues) {
grid-row: row1-start / row2-end;
}
@media (max-width: 1150px) {
grid-area: unset;
&:global(.OnlyClusterIssues) {
grid-row: auto;
}
}
.SubHeader {
padding-top: 0;
padding-bottom: 0;
:global(.Icon) {
font-size: 130%;
color: var(--colorError);
}
}
.message {
white-space: nowrap;
text-overflow: ellipsis;
flex-grow: 3;
}
.object {
white-space: nowrap;
text-overflow: ellipsis;
flex-grow: 2;
}
.noIssues {
.Icon {
color: white;
}
.title {
font-size: large;
color: var(--textColorAccent);
font-weight: bold;
}
}
}
@@ -0,0 +1,202 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./cluster-issues.module.scss";
import React from "react";
import { observer } from "mobx-react";
import type { IComputedValue } from "mobx";
import { computed, makeObservable } from "mobx";
import { Icon } from "../icon";
import { SubHeader } from "../layout/sub-header";
import { Table, TableCell, TableHead, TableRow } from "../table";
import { cssNames, prevDefault } from "../../utils";
import type { ItemObject } from "../../../common/item.store";
import { Spinner } from "../spinner";
import type { ApiManager } from "../../../common/k8s-api/api-manager";
import { KubeObjectAge } from "../kube-object/age";
import { withInjectables } from "@ogre-tools/injectable-react";
import type { NodeStore } from "../+nodes/store";
import type { EventStore } from "../+events/store";
import apiManagerInjectable from "../../../common/k8s-api/api-manager/manager.injectable";
import eventStoreInjectable from "../+events/store.injectable";
import nodeStoreInjectable from "../+nodes/store.injectable";
import type { PageParam } from "../../navigation/page-param";
import type { ToggleKubeDetailsPane } from "../kube-detail-params/toggle-details.injectable";
import kubeSelectedUrlParamInjectable from "../kube-detail-params/kube-selected-url.injectable";
import toggleKubeDetailsPaneInjectable from "../kube-detail-params/toggle-details.injectable";
import type { LensTheme } from "../../themes/lens-theme";
import activeThemeInjectable from "../../themes/active.injectable";
export interface ClusterIssuesProps {
className?: string;
}
interface Warning extends ItemObject {
kind: string;
message: string | undefined;
selfLink: string;
renderAge: () => React.ReactElement;
ageMs: number;
}
enum sortBy {
type = "type",
object = "object",
age = "age",
}
interface Dependencies {
activeTheme: IComputedValue<LensTheme>;
nodeStore: NodeStore;
eventStore: EventStore;
apiManager: ApiManager;
kubeSelectedUrlParam: PageParam<string>;
toggleKubeDetailsPane: ToggleKubeDetailsPane;
}
@observer
class NonInjectedClusterIssues extends React.Component<ClusterIssuesProps & Dependencies> {
constructor(props: ClusterIssuesProps & Dependencies) {
super(props);
makeObservable(this);
}
@computed get warnings(): Warning[] {
return [
...this.props.nodeStore.items.flatMap(node => (
node.getWarningConditions()
.map(({ message }) => ({
selfLink: node.selfLink,
getId: node.getId,
getName: node.getName,
kind: node.kind,
message,
renderAge: () => <KubeObjectAge key="age" object={node} />,
ageMs: -node.getCreationTimestamp(),
}))
)),
...this.props.eventStore.getWarnings().map(warning => ({
getId: () => warning.involvedObject.uid,
getName: () => warning.involvedObject.name,
renderAge: () => <KubeObjectAge key="age" object={warning} />,
ageMs: -warning.getCreationTimestamp(),
message: warning.message,
kind: warning.kind,
selfLink: this.props.apiManager.lookupApiLink(warning.involvedObject, warning),
})),
];
}
getTableRow = (uid: string) => {
const { warnings } = this;
const { kubeSelectedUrlParam, toggleKubeDetailsPane: toggleDetails } = this.props;
const warning = warnings.find(warn => warn.getId() == uid);
if (!warning) {
return undefined;
}
const { getId, getName, message, kind, selfLink, renderAge } = warning;
return (
<TableRow
key={getId()}
sortItem={warning}
selected={selfLink === kubeSelectedUrlParam.get()}
onClick={prevDefault(() => toggleDetails(selfLink))}
>
<TableCell className={styles.message}>
{message ?? "<unknown>"}
</TableCell>
<TableCell className={styles.object}>
{getName()}
</TableCell>
<TableCell className="kind">
{kind}
</TableCell>
<TableCell className="age">
{renderAge()}
</TableCell>
</TableRow>
);
};
renderContent() {
const { warnings } = this;
if (!this.props.eventStore.isLoaded) {
return (
<Spinner center/>
);
}
if (!warnings.length) {
return (
<div className={cssNames(styles.noIssues, "flex column box grow gaps align-center justify-center")}>
<Icon
className={styles.Icon}
material="check"
big
sticker
/>
<p className={styles.title}>No issues found</p>
<p>Everything is fine in the Cluster</p>
</div>
);
}
return (
<>
<SubHeader className={styles.SubHeader}>
<Icon material="error_outline"/>
{` Warnings: ${warnings.length}`}
</SubHeader>
<Table
tableId="cluster_issues"
items={warnings}
virtual
selectable
sortable={{
[sortBy.type]: warning => warning.kind,
[sortBy.object]: warning => warning.getName(),
[sortBy.age]: warning => warning.ageMs,
}}
sortByDefault={{ sortBy: sortBy.object, orderBy: "asc" }}
sortSyncWithUrl={false}
getTableRow={this.getTableRow}
className={cssNames("box grow", this.props.activeTheme.get().type)}
>
<TableHead nowrap>
<TableCell className="message">Message</TableCell>
<TableCell className="object" sortBy={sortBy.object}>Object</TableCell>
<TableCell className="kind" sortBy={sortBy.type}>Type</TableCell>
<TableCell className="timestamp" sortBy={sortBy.age}>Age</TableCell>
</TableHead>
</Table>
</>
);
}
render() {
return (
<div className={cssNames(styles.ClusterIssues, "flex column", this.props.className)}>
{this.renderContent()}
</div>
);
}
}
export const ClusterIssues = withInjectables<Dependencies, ClusterIssuesProps>(NonInjectedClusterIssues, {
getProps: (di, props) => ({
...props,
activeTheme: di.inject(activeThemeInjectable),
apiManager: di.inject(apiManagerInjectable),
eventStore: di.inject(eventStoreInjectable),
nodeStore: di.inject(nodeStoreInjectable),
kubeSelectedUrlParam: di.inject(kubeSelectedUrlParamInjectable),
toggleKubeDetailsPane: di.inject(toggleKubeDetailsPaneInjectable),
}),
});
@@ -0,0 +1,83 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import React from "react";
import { observer } from "mobx-react";
import type { NodeStore } from "../+nodes/store";
import { Radio, RadioGroup } from "../radio";
import type { ClusterOverviewStore } from "./cluster-overview-store/cluster-overview-store";
import { MetricNodeRole, MetricType } from "./cluster-overview-store/cluster-overview-store";
import clusterOverviewStoreInjectable from "./cluster-overview-store/cluster-overview-store.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import nodeStoreInjectable from "../+nodes/store.injectable";
import { normalizeMetrics } from "../../../common/k8s-api/endpoints/metrics.api";
interface Dependencies {
clusterOverviewStore: ClusterOverviewStore;
nodeStore: NodeStore;
}
const NonInjectedClusterMetricSwitchers = observer(({
clusterOverviewStore,
nodeStore,
}: Dependencies) => {
const { masterNodes, workerNodes } = nodeStore;
const { cpuUsage, memoryUsage } = clusterOverviewStore.metrics ?? {};
const hasMasterNodes = masterNodes.length > 0;
const hasWorkerNodes = workerNodes.length > 0;
const hasCpuMetrics = normalizeMetrics(cpuUsage).data.result[0].values.length > 0;
const hasMemoryMetrics = normalizeMetrics(memoryUsage).data.result[0].values.length > 0;
return (
<div className="flex gaps" style={{ marginBottom: "calc(var(--margin) * 2)" }}>
<div className="box grow">
<RadioGroup
asButtons
className="RadioGroup flex gaps"
value={clusterOverviewStore.metricNodeRole}
onChange={metric => clusterOverviewStore.metricNodeRole = metric}
>
<Radio
label="Master"
value={MetricNodeRole.MASTER}
disabled={!hasMasterNodes}
/>
<Radio
label="Worker"
value={MetricNodeRole.WORKER}
disabled={!hasWorkerNodes}
/>
</RadioGroup>
</div>
<div className="box grow" style={{ textAlign: "right" }}>
<RadioGroup
asButtons
className="RadioGroup flex gaps"
value={clusterOverviewStore.metricType}
onChange={value => clusterOverviewStore.metricType = value}
>
<Radio
label="CPU"
value={MetricType.CPU}
disabled={!hasCpuMetrics}
/>
<Radio
label="Memory"
value={MetricType.MEMORY}
disabled={!hasMemoryMetrics}
/>
</RadioGroup>
</div>
</div>
);
});
export const ClusterMetricSwitchers = withInjectables<Dependencies>(NonInjectedClusterMetricSwitchers, {
getProps: (di) => ({
clusterOverviewStore: di.inject(clusterOverviewStoreInjectable),
nodeStore: di.inject(nodeStoreInjectable),
}),
});
@@ -0,0 +1,23 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.ClusterMetrics {
position: relative;
min-height: 280px;
padding: calc(var(--margin) * 2);
background: var(--contentColor);
.chart {
:global(.chart-container) {
width: 100%;
height: 100%;
}
}
.empty {
text-align: center;
padding-bottom: 45px;
}
}
@@ -0,0 +1,129 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./cluster-metrics.module.scss";
import React, { useState } from "react";
import { observer } from "mobx-react";
import type { ChartOptions, ChartPoint } from "chart.js";
import type { ClusterOverviewStore } from "./cluster-overview-store/cluster-overview-store";
import { MetricType } from "./cluster-overview-store/cluster-overview-store";
import { BarChart } from "../chart";
import { bytesToUnits, cssNames } from "../../utils";
import { Spinner } from "../spinner";
import { ZebraStripesPlugin } from "../chart/zebra-stripes.plugin";
import { ClusterNoMetrics } from "./cluster-no-metrics";
import { ClusterMetricSwitchers } from "./cluster-metric-switchers";
import { getMetricLastPoints } from "../../../common/k8s-api/endpoints/metrics.api";
import { withInjectables } from "@ogre-tools/injectable-react";
import clusterOverviewStoreInjectable from "./cluster-overview-store/cluster-overview-store.injectable";
interface Dependencies {
clusterOverviewStore: ClusterOverviewStore;
}
const NonInjectedClusterMetrics = observer(({ clusterOverviewStore: { metricType, metricNodeRole, getMetricsValues, metricsLoaded, metrics }}: Dependencies) => {
const [plugins] = useState([new ZebraStripesPlugin()]);
const { memoryCapacity, cpuCapacity } = getMetricLastPoints(metrics ?? {});
const metricValues = getMetricsValues(metrics ?? {});
const colors = { cpu: "#3D90CE", memory: "#C93DCE" };
const data = metricValues.map(value => ({
x: value[0],
y: parseFloat(value[1]).toFixed(3),
}));
const datasets = [{
id: metricType + metricNodeRole,
label: `${metricType.toUpperCase()} usage`,
borderColor: colors[metricType],
data,
}];
const cpuOptions: ChartOptions = {
scales: {
yAxes: [{
ticks: {
suggestedMax: cpuCapacity,
callback: (value) => value,
},
}],
},
tooltips: {
callbacks: {
label: ({ index }, data) => {
if (!index) {
return "<unknown>";
}
const value = data.datasets?.[0].data?.[index] as ChartPoint;
return value.y?.toString() ?? "<unknown>";
},
},
},
};
const memoryOptions: ChartOptions = {
scales: {
yAxes: [{
ticks: {
suggestedMax: memoryCapacity,
callback: (value: string) => !value ? 0 : bytesToUnits(parseInt(value)),
},
}],
},
tooltips: {
callbacks: {
label: ({ index }, data) => {
if (!index) {
return "<unknown>";
}
const value = data.datasets?.[0].data?.[index] as ChartPoint;
return bytesToUnits(parseInt(value.y as string), { precision: 3 });
},
},
},
};
const options = metricType === MetricType.CPU ? cpuOptions : memoryOptions;
const renderMetrics = () => {
if (!metricValues.length && !metricsLoaded) {
return <Spinner center/>;
}
if (!memoryCapacity || !cpuCapacity) {
return <ClusterNoMetrics className={styles.empty}/>;
}
return (
<BarChart
name={`${metricNodeRole}-${metricType}`}
options={options}
data={{ datasets }}
timeLabelStep={5}
showLegend={false}
plugins={plugins}
className={styles.chart}
/>
);
};
return (
<div className={cssNames(styles.ClusterMetrics, "flex column")}>
<ClusterMetricSwitchers/>
{renderMetrics()}
</div>
);
});
export const ClusterMetrics = withInjectables<Dependencies>(
NonInjectedClusterMetrics,
{
getProps: (di) => ({
clusterOverviewStore: di.inject(clusterOverviewStoreInjectable),
}),
},
);
@@ -0,0 +1,18 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.ClusterNoMetrics {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
line-height: 1.7;
flex-grow: 1;
.link {
color: var(--blue);
cursor: pointer;
}
}
@@ -0,0 +1,55 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./cluster-no-metrics.module.scss";
import React from "react";
import { Icon } from "../icon";
import { cssNames } from "../../utils";
import type { NavigateToEntitySettings } from "../../../common/front-end-routing/routes/entity-settings/navigate-to-entity-settings.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import navigateToEntitySettingsInjectable from "../../../common/front-end-routing/routes/entity-settings/navigate-to-entity-settings.injectable";
import hostedClusterInjectable from "../../cluster-frame-context/hosted-cluster.injectable";
export interface ClusterNoMetricsProps {
className: string;
}
interface Dependencies {
navigateToEntitySettings: NavigateToEntitySettings;
clusterId: string | undefined;
}
export function NonInjectedClusterNoMetrics({ className, navigateToEntitySettings, clusterId }: Dependencies & ClusterNoMetricsProps) {
function openMetricSettingsPage() {
if (clusterId) {
navigateToEntitySettings(clusterId, "metrics");
}
}
return (
<div className={cssNames(styles.ClusterNoMetrics, className)} data-testid="no-metrics-message">
<Icon material="info"/>
<p>Metrics are not available due to missing or invalid Prometheus configuration.</p>
<p><span className={styles.link} onClick={openMetricSettingsPage}>Open cluster settings</span></p>
</div>
);
}
export const ClusterNoMetrics = withInjectables<Dependencies, ClusterNoMetricsProps>(
NonInjectedClusterNoMetrics,
{
getProps: (di, props) => {
const cluster = di.inject(hostedClusterInjectable);
return {
navigateToEntitySettings: di.inject(navigateToEntitySettingsInjectable),
clusterId: cluster?.id,
...props,
};
},
},
);
@@ -0,0 +1,21 @@
/**
* 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 { routeSpecificComponentInjectionToken } from "../../routes/route-specific-component-injection-token";
import { ClusterOverview } from "./cluster-overview";
import clusterOverviewRouteInjectable from "../../../common/front-end-routing/routes/cluster/overview/cluster-overview-route.injectable";
const clusterOverviewRouteComponentInjectable = getInjectable({
id: "cluster-overview-route-component",
instantiate: (di) => ({
route: di.inject(clusterOverviewRouteInjectable),
Component: ClusterOverview,
}),
injectionToken: routeSpecificComponentInjectionToken,
});
export default clusterOverviewRouteComponentInjectable;
@@ -0,0 +1,43 @@
/**
* 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 { Icon } from "../icon";
import React from "react";
import type {
SidebarItemRegistration } from "../layout/sidebar-items.injectable";
import {
sidebarItemsInjectionToken,
} from "../layout/sidebar-items.injectable";
import { computed } from "mobx";
import clusterOverviewRouteInjectable from "../../../common/front-end-routing/routes/cluster/overview/cluster-overview-route.injectable";
import routeIsActiveInjectable from "../../routes/route-is-active.injectable";
import navigateToClusterOverviewInjectable from "../../../common/front-end-routing/routes/cluster/overview/navigate-to-cluster-overview.injectable";
const clusterOverviewSidebarItemsInjectable = getInjectable({
id: "cluster-overview-sidebar-items",
instantiate: (di) => {
const route = di.inject(clusterOverviewRouteInjectable);
const navigateToClusterOverview = di.inject(navigateToClusterOverviewInjectable);
const routeIsActive = di.inject(routeIsActiveInjectable, route);
return computed((): SidebarItemRegistration[] => [
{
id: "cluster-overview",
parentId: null,
title: "Cluster",
getIcon: () => <Icon svg="kube" />,
onClick: navigateToClusterOverview,
isActive: routeIsActive,
isVisible: route.isEnabled,
orderNumber: 10,
},
]);
},
injectionToken: sidebarItemsInjectionToken,
});
export default clusterOverviewSidebarItemsInjectable;
Loaded 100 of 3321 files, more files were not shown because too many files have changed in this diff. Show more