mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
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:
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;
|
||||
+32
@@ -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;
|
||||
+53
@@ -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;
|
||||
+23
@@ -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;
|
||||
+24
@@ -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;
|
||||
+93
@@ -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;
|
||||
+32
@@ -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;
|
||||
+80
@@ -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;
|
||||
+56
@@ -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;
|
||||
+24
@@ -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;
|
||||
+23
@@ -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;
|
||||
+34
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
+28
@@ -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;
|
||||
}
|
||||
+21
@@ -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;
|
||||
+64
@@ -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;
|
||||
+21
@@ -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");
|
||||
});
|
||||
});
|
||||
+54
@@ -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();
|
||||
});
|
||||
});
|
||||
+163
@@ -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;
|
||||
+19
@@ -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;
|
||||
+86
@@ -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),
|
||||
}),
|
||||
});
|
||||
+22
@@ -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",
|
||||
});
|
||||
+64
@@ -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;
|
||||
+55
@@ -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;
|
||||
+35
@@ -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;
|
||||
+36
@@ -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;
|
||||
+43
@@ -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;
|
||||
}
|
||||
+33
@@ -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;
|
||||
+39
@@ -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;
|
||||
+34
@@ -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;
|
||||
+39
@@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
+21
@@ -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;
|
||||
+43
@@ -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
Reference in new issue
Block a user