Turn on strict mode in tsconfig.json, some helpful lints, and required cleanup where strictness necessitates it (#5195)

* Turn on strict mode in tsconfig.json

- Add route, clusterRoute, and payloadValidatedClusterRoute helper
  functions to improve types with backend routes

- Turn on the following new lints:
  - react/jsx-first-prop-new-line
  - react/jsx-wrap-multilines
  - react/jsx-one-expression-per-line
  - react/jsx-max-props-per-line
  - react/jsx-indent
  - react/jsx-indent-props

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

* fix build

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

* Replace KubeObject scope strings with enum

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

* Revert package.json version changes

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

* revert move hostedCluster(Id)

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

* change some type param names to be not single letters

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

* remove copy-extension-themes

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

* add new make clean action

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

* fix build to not use webpack for generating types

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

* fix kube-object-menu.test.tsx

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

* fix select.test.tsx

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

* fix catalog.test.tsx

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

* revert move fileNameMigration to index

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

* fix ref logic error

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

* fix log-resource-selector.test.tsx tests

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

* fix dock-store.test.ts test by overriding createStorage to not touch file system

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

* fix cluster.test.ts tests

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

* fix kube=api.test.ts

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

* fixed hotbar-store.test.ts

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

* fix kubeconfig-manager.test.ts

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

* fix cluster-role-bindings/__tests__/dialog.test.tsx

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

* fix role-bindings/__tests__/dialog.test.tsx

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

* fix pods.test.ts

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

* fix delete-cluster-dialog.test.tsx

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

* fix daemonset.store.test.ts

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

* fix replicaset.store.test.ts

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

* fix statefulsets/dialog/dialog.test.tsx

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

* fix replicasets/scale-dialog/dialog.test.tsx

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

* fix deployments.store.test.ts

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

* fix deployments/scale/dialog.test.tsx

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

* fix cronjob.store.test.ts

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

* fix stateful-set.api.test.ts

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

* fix deployment.api.test.ts

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

* fix api-manager.test.ts

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

* fix statefulset.store.test.ts

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

* fix job.store.test.ts

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

* fix pods.store.test.ts

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

* fix scroll-spy.test.tsx

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

* fix hotbar-remove-command.test.tsx

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

* fix catalog-entity-registry.test.ts

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

* fix welcome.test.tsx

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

* fix verify-that-all-routes-have-route-component.test.ts

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

* fix pod-tolerations.test.tsx

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

* better fix for previous 3 fixes, plus also select.test.tsx

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

* fix kube-object-menu.test.tsx

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

* fix app-paths.test.ts

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

* fix dock-tabs.test.tsx

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

* fix isReactNode typing

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

* fix sub-title.tsx

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

* fix drawer.tsx

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

* fix list-layout.tsx and header.tsx

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

* fix error-boundary.tsx

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

* fix upgrade-chart/store.ts and dock-tab.store.ts

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

* fix install-chart/store.ts

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

* fix edit-resource/store.ts

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

* fix create-resource/store.ts

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

* fix namespace-select.tsx

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

* fix namespace-select-filter.tsx

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

* fix crd-list.tsx

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

* fix wrong types for extensions

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

* fix circular dependency

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

* fix circular dependency on catalogCategoryRegistry

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

* fix api-kube

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

* fix type errors, most <Select /> errors

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

* fixing more type errors

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

* some more fixing type errors

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

* convert all KubeApis to injectable with legacy global backups

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

* factor out into a common file all the exports

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

* convert all KubeObjectStores to injectable with legacy global backups

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

* fix lint

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

* remove unused legacy KubeApi globals

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

* fix bad previous commit

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

* more crash fixing

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

* try and fix behavioural tests

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

* fix sidebar-and-tab-navigation-for-core.test.tsx

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

* fix sidebar-and-tab-navigation-for-extensions.test.tsx

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

* fix navigation-using-application-menu.test.ts

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

* fix catalog.test.tsx

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

* Make ThemeStore non-singleton and fix navigation-to-terminal-preferences.test.ts

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

* extensions.test.tsx

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

* fix catalog-entity-registry.test.ts

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

* fix navigation-using-application-menu.test.ts

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

* fix log-resource-selector.test.tsx

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

* fix dock-tabs.test.tsx

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

* fix delete-cluster-dialog.test.tsx

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

* fix navigation-to-kubernetes-preferences.test.ts

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

* fix navigation-to-editor-preferences.test.ts

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

* fix navigation-to-proxy-preferences.test.ts

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

* fix navigation-using-application-menu.test.ts

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

* fix navigation-to-application-preferences.test.ts

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

* fix dock-store.test.ts

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

* fix select.test.tsx

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

* fix role-bindings/__tests__/dialog.test.tsx

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

* fix hotbar-remove-command.test.tsx

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

* fix cluster-role-bindings/__tests__/dialog.test.tsx

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

* fix navigation-to-extension-specific-preferences.test.tsx

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

* fix navigation-to-telemetry-preferences.test.tsx

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

* fix closing-preferences.test.tsx

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

* fix navigation-to-editor-preferences.test.ts

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

* fix navigation-to-proxy-preferences.test.ts

- Fix other type errors too

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

* final tweaks

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

* Add more tsconfig files, fix bug in <Catalog>

- Make all of history, navigation injectable

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

* fix type errors

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

* Convert all of kube-details-params/ and navigate/ to injectable

- This fixes a runtime error that was encountered during testing

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

* Fix runtime errors on renderer

- remove all static uses of `createPageParam` (and then removed the
  legacy global)
- Made LensRendererExtension and LensMainExtension just used
  dependencies and not the getLegacyDi
- Fixed circular dep in extension-loader

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

* Move registerStore calls to after injectMany

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

* replace all the rest of the legacy uses of apiManager

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

* fix stack overflow and cycles in DI

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

* fix NamespaceSelectFilter not opening

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

* fix WizardStep and AddNamespaceDialog

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

* fix KubeApi's not being registered

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

* cleanup WindowManager

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

* Proper fix for Wizard, fix NamespaceStore.subscribe

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

* Rewrite withTooltip to be more type correct

- Fixes mobx related "too many recursive actions" error

- Change all the uses of withTooltips to be functional components

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

* Add e2e test to cover kube api registration

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

* cleanup internal-commands

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

* remove cast in <Animate>

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

* Fix command-palette e2e test

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

* Fix type error after rebase

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

* Fix test name

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

* fix lint

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

* fix code to help CodeQL scanner

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

* update intree extension lock files

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

* Fix build-extensions picking wrong @types/react

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

* fix tests from rebase

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

* fix type error

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

* Make KubeconfigSyncManager more injectable

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

* fix crash in test mode for Dialog

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

* make Select snapshots deterministic

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

* fix new type error

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

* fix kube-object.store.test.ts typing

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

* fix merge build issues

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

* fix snapshots after merge

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

* fix lint after merge

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

* reexport BaseKubeJsonApiObjectMetadata

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

* fix typo in terminalSpawningPool

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

* remove duplicate license header

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

* fix typo to waitUntilDefined

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

* remove iter use from getLegacyGlobalDiForExtensionApi

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

* remove complex createStorage override

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

* override logger with mocks only when needed for tests

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

* remove specialized overrideStore flags for getDiForUnitTesting

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

* remove unnecessary | undefined types from the exactOptionalFieldTypes experiment

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

* use more descriptive name for local test mocks

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

* remove unnecessary addition to 'make clean' target

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

* remove oddity of KubeObjectStore.getById(undefined) being allowed

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

* rename KubeObject.getDescriptor in favour of name without fundemental JS meaning

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

* Simplify legacyRegisterApi when working in behaviour unit tests

- Don't emit within main environment as there should be no auto
  registering there

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

* change confusing variable name in ReactiveDuration

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

* make visitor pattern more explicit for Entity contextMenuOpen

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

* toggleDetails -> toggleKubeDetailsPane is more specific

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

* remove outdated comment

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

* fix bug where LensExtension dependencies are not set

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

* Fix tests from the revert of react 18

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

* fix more tests from merge

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

* fix typings with new is-compatible-extension tests

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

* more type fixing

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

* Revert in-tree extension versions

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

* Improve name of guarding injectable for stores and apis

- New name better implies that it is just a guard state and does not do
  anything

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

* Add helper for <Select>.isMulti for storing in a Set<Value>

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

* fix is-compatible-extension.test.ts types

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton authored and GitHub committed 2022-05-16 07:17:57 -04:00
1 parent 381d77c633
commit dfcb7c3330
1062 files changed
+26512 -28689

No files matched your search

@@ -3,21 +3,19 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { CatalogEntityRegistry } from "../catalog-entity-registry";
import { catalogCategoryRegistry } from "../../../common/catalog/catalog-category-registry";
import type { CatalogEntityData, CatalogEntityKindData } from "../catalog-entity";
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 TestCatalogEntityRegistry extends CatalogEntityRegistry {
replaceItems(items: Array<CatalogEntityData & CatalogEntityKindData>) {
this.updateItems(items);
}
}
class FooBarCategory extends CatalogCategory {
public readonly apiVersion = "catalog.k8slens.dev/v1alpha1";
public readonly apiVersion = "entityRegistry.k8slens.dev/v1alpha1";
public readonly kind = "CatalogCategory";
public metadata = {
name: "FooBars",
@@ -26,10 +24,7 @@ class FooBarCategory extends CatalogCategory {
public spec = {
group: "entity.k8slens.dev",
versions: [
{
name: "v1alpha1",
entityClass: WebLink,
},
categoryVersion("v1alpha1", WebLink),
],
names: {
kind: "FooBar",
@@ -81,9 +76,18 @@ const entitykc = new KubernetesCluster({
});
describe("CatalogEntityRegistry", () => {
let entityRegistry: CatalogEntityRegistry;
let catalogCategoryRegistry: CatalogCategoryRegistry;
beforeEach(() => {
const di = getDiForUnitTesting();
entityRegistry = di.inject(catalogEntityRegistryInjectable);
catalogCategoryRegistry = di.inject(catalogCategoryRegistryInjectable);
});
describe("updateItems", () => {
it("adds new catalog item", () => {
const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry);
const items = [{
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "KubernetesCluster",
@@ -99,8 +103,8 @@ describe("CatalogEntityRegistry", () => {
spec: {},
}];
catalog.replaceItems(items);
expect(catalog.items.length).toEqual(1);
entityRegistry.updateItems(items);
expect(entityRegistry.items.get().length).toEqual(1);
items.push({
apiVersion: "entity.k8slens.dev/v1alpha1",
@@ -117,12 +121,11 @@ describe("CatalogEntityRegistry", () => {
spec: {},
});
catalog.replaceItems(items);
expect(catalog.items.length).toEqual(2);
entityRegistry.updateItems(items);
expect(entityRegistry.items.get().length).toEqual(2);
});
it("updates existing items", () => {
const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry);
const items = [{
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "KubernetesCluster",
@@ -138,19 +141,18 @@ describe("CatalogEntityRegistry", () => {
spec: {},
}];
catalog.replaceItems(items);
expect(catalog.items.length).toEqual(1);
expect(catalog.items[0].status.phase).toEqual("disconnected");
entityRegistry.updateItems(items);
expect(entityRegistry.items.get().length).toEqual(1);
expect(entityRegistry.items.get()[0].status.phase).toEqual("disconnected");
items[0].status.phase = "connected";
catalog.replaceItems(items);
expect(catalog.items.length).toEqual(1);
expect(catalog.items[0].status.phase).toEqual("connected");
entityRegistry.updateItems(items);
expect(entityRegistry.items.get().length).toEqual(1);
expect(entityRegistry.items.get()[0].status.phase).toEqual("connected");
});
it("updates activeEntity", () => {
const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry);
const items = [{
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "KubernetesCluster",
@@ -166,17 +168,16 @@ describe("CatalogEntityRegistry", () => {
spec: {},
}];
catalog.replaceItems(items);
catalog.activeEntity = catalog.items[0];
expect(catalog.activeEntity.status.phase).toEqual("disconnected");
entityRegistry.updateItems(items);
entityRegistry.activeEntity = entityRegistry.items.get()[0];
expect(entityRegistry.activeEntity.status.phase).toEqual("disconnected");
items[0].status.phase = "connected";
catalog.replaceItems(items);
expect(catalog.activeEntity.status.phase).toEqual("connected");
entityRegistry.updateItems(items);
expect(entityRegistry.activeEntity.status.phase).toEqual("connected");
});
it("removes deleted items", () => {
const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry);
const items = [
{
apiVersion: "entity.k8slens.dev/v1alpha1",
@@ -208,17 +209,16 @@ describe("CatalogEntityRegistry", () => {
},
];
catalog.replaceItems(items);
entityRegistry.updateItems(items);
items.splice(0, 1);
catalog.replaceItems(items);
expect(catalog.items.length).toEqual(1);
expect(catalog.items[0].metadata.uid).toEqual("456");
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 catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry);
const items = [
{
apiVersion: "entity.k8slens.dev/v1alpha1",
@@ -250,13 +250,12 @@ describe("CatalogEntityRegistry", () => {
},
];
catalog.replaceItems(items);
expect(catalog.items.length).toBe(1);
entityRegistry.updateItems(items);
expect(entityRegistry.items.get().length).toBe(1);
});
});
it("does return items after matching category is added", () => {
const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry);
const items = [
{
apiVersion: "entity.k8slens.dev/v1alpha1",
@@ -274,29 +273,28 @@ describe("CatalogEntityRegistry", () => {
},
];
catalog.replaceItems(items);
entityRegistry.updateItems(items);
catalogCategoryRegistry.add(new FooBarCategory());
expect(catalog.items.length).toBe(1);
expect(entityRegistry.items.get().length).toBe(1);
});
it("does not return items that are filtered out", () => {
const source = observable.array([entity, entity2, entitykc]);
const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry);
catalog.replaceItems(source);
entityRegistry.updateItems(source);
expect(catalog.items.length).toBe(3);
expect(catalog.filteredItems.length).toBe(3);
expect(entityRegistry.items.get().length).toBe(3);
expect(entityRegistry.filteredItems.length).toBe(3);
const d = catalog.addCatalogFilter(entity => entity.kind === KubernetesCluster.kind);
const d = entityRegistry.addCatalogFilter(entity => entity.kind === KubernetesCluster.kind);
expect(catalog.items.length).toBe(3);
expect(catalog.filteredItems.length).toBe(1);
expect(entityRegistry.items.get().length).toBe(3);
expect(entityRegistry.filteredItems.length).toBe(1);
// Remove filter
d();
expect(catalog.items.length).toBe(3);
expect(catalog.filteredItems.length).toBe(3);
expect(entityRegistry.items.get().length).toBe(3);
expect(entityRegistry.filteredItems.length).toBe(3);
});
});
@@ -3,5 +3,9 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export { catalogCategoryRegistry } from "../../common/catalog";
import catalogCategoryRegistryInjectable from "../../common/catalog/category-registry.injectable";
import { asLegacyGlobalForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api";
export type { CategoryFilter } from "../../common/catalog";
export const catalogCategoryRegistry = asLegacyGlobalForExtensionApi(catalogCategoryRegistryInjectable);
@@ -1,13 +0,0 @@
/**
* 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 { catalogEntityRegistry } from "../catalog-entity-registry";
const catalogEntityRegistryInjectable = getInjectable({
id: "catalog-entity-registry",
instantiate: () => catalogEntityRegistry,
});
export default catalogEntityRegistryInjectable;
-1
View File
@@ -3,7 +3,6 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export { catalogEntityRunContext } from "./catalog-entity-registry";
export { CatalogCategory, CatalogEntity } from "../../common/catalog";
export type {
CatalogEntityData,
@@ -0,0 +1,18 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { computed } from "mobx";
import catalogEntityRegistryInjectable from "./registry.injectable";
const activeEntityInjectable = getInjectable({
id: "active-entity",
instantiate: (di) => {
const registry = di.inject(catalogEntityRegistryInjectable);
return computed(() => registry.activeEntity);
},
});
export default activeEntityInjectable;
@@ -0,0 +1,13 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import catalogEntityRegistryInjectable from "./registry.injectable";
const catalogEnitiesInjectable = getInjectable({
id: "catalog-enities",
instantiate: (di) => di.inject(catalogEntityRegistryInjectable).items,
});
export default catalogEnitiesInjectable;
@@ -0,0 +1,22 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import clusterStoreInjectable from "../../../../common/cluster-store/cluster-store.injectable";
import type { Cluster } from "../../../../common/cluster/cluster";
import catalogEntityRegistryInjectable from "./registry.injectable";
export type GetActiveClusterEntity = () => Cluster | undefined;
const getActiveClusterEntityInjectable = getInjectable({
id: "get-active-cluster-entity",
instantiate: (di): GetActiveClusterEntity => {
const store = di.inject(clusterStoreInjectable);
const entityRegistry = di.inject(catalogEntityRegistryInjectable);
return () => store.getById(entityRegistry.activeEntity?.getId());
},
});
export default getActiveClusterEntityInjectable;
@@ -0,0 +1,12 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { asLegacyGlobalFunctionForExtensionApi } from "../../../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api";
import getActiveClusterEntityInjectable from "./get-active-cluster-entity.injectable";
/**
* @deprecated use `di.inject(getActiveClusterEntityInjectable)` instead
*/
export const getActiveClusterEntity = asLegacyGlobalFunctionForExtensionApi(getActiveClusterEntityInjectable);
@@ -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 catalogCategoryRegistryInjectable from "../../../../common/catalog/category-registry.injectable";
import navigateInjectable from "../../../navigation/navigate.injectable";
import { CatalogEntityRegistry } from "./registry";
const catalogEntityRegistryInjectable = getInjectable({
id: "catalog-entity-registry",
instantiate: (di) => new CatalogEntityRegistry({
categoryRegistry: di.inject(catalogCategoryRegistryInjectable),
navigate: di.inject(navigateInjectable),
}),
});
export default catalogEntityRegistryInjectable;
@@ -4,56 +4,57 @@
*/
import { computed, observable, makeObservable, action } from "mobx";
import { ipcRendererOn } from "../../common/ipc";
import type { CatalogCategory, CatalogEntity, CatalogEntityData, CatalogCategoryRegistry, CatalogEntityKindData } from "../../common/catalog";
import { catalogCategoryRegistry } from "../../common/catalog";
import "../../common/catalog-entities";
import type { Cluster } from "../../common/cluster/cluster";
import { ClusterStore } from "../../common/cluster-store/cluster-store";
import { iter } from "../utils";
import type { Disposer } from "../utils";
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 logger from "../../common/logger";
import { CatalogRunEvent } from "../../common/catalog/catalog-run-event";
import logger from "../../../../common/logger";
import { CatalogRunEvent } from "../../../../common/catalog/catalog-run-event";
import { ipcRenderer } from "electron";
import { catalogInitChannel, catalogItemsChannel, catalogEntityRunListener } from "../../common/ipc/catalog";
import { navigate } from "../navigation";
import { catalogInitChannel, catalogItemsChannel, catalogEntityRunListener } from "../../../../common/ipc/catalog";
import { isMainFrame } from "process";
import type { Navigate } from "../../../navigation/navigate.injectable";
export type EntityFilter = (entity: CatalogEntity) => any;
export type CatalogEntityOnBeforeRun = (event: CatalogRunEvent) => void | Promise<void>;
export const catalogEntityRunContext = {
navigate: (url: string) => navigate(url),
setCommandPaletteContext: (entity?: CatalogEntity) => {
catalogEntityRegistry.activeEntity = entity;
},
};
interface Dependencies {
navigate: Navigate;
readonly categoryRegistry: CatalogCategoryRegistry;
}
export class CatalogEntityRegistry {
@observable protected activeEntityId: string | undefined = undefined;
protected _entities = observable.map<string, CatalogEntity>([], { deep: true });
protected filters = observable.set<EntityFilter>([], {
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 onBeforeRunHooks = observable.set<CatalogEntityOnBeforeRun>([], {
protected readonly onBeforeRunHooks = observable.set<CatalogEntityOnBeforeRun>([], {
deep: false,
});
/**
* Buffer for keeping entities that don't yet have CatalogCategory synced
*/
protected rawEntities: (CatalogEntityData & CatalogEntityKindData)[] = [];
protected readonly rawEntities: (CatalogEntityData & CatalogEntityKindData)[] = [];
constructor(private categoryRegistry: CatalogCategoryRegistry) {
constructor(protected readonly dependencies: Dependencies) {
makeObservable(this);
}
protected getActiveEntityById() {
return this._entities.get(this.activeEntityId) || null;
const activeEntityId = this.activeEntityId.get();
if (!activeEntityId) {
return undefined;
}
return this._entities.get(activeEntityId);
}
get activeEntity(): CatalogEntity | null {
get activeEntity(): CatalogEntity | undefined {
const entity = this.getActiveEntityById();
// If the entity was not found but there are rawEntities to be processed,
@@ -68,15 +69,15 @@ export class CatalogEntityRegistry {
return entity;
}
set activeEntity(raw: CatalogEntity | string | null) {
set activeEntity(raw: CatalogEntity | string | undefined) {
if (raw) {
const id = typeof raw === "string"
? raw
: raw.getId();
this.activeEntityId = id;
this.activeEntityId.set(id);
} else {
this.activeEntityId = undefined;
this.activeEntityId.set(undefined);
}
}
@@ -119,7 +120,7 @@ export class CatalogEntityRegistry {
const existing = this._entities.get(item.metadata.uid);
if (!existing) {
const entity = this.categoryRegistry.getEntityForData(item);
const entity = this.dependencies.categoryRegistry.getEntityForData(item);
if (entity) {
this._entities.set(entity.getId(), entity);
@@ -143,25 +144,25 @@ export class CatalogEntityRegistry {
}
}
@computed get items() {
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.values(),
this.items.get().values(),
),
);
}
@computed get entities(): Map<string, CatalogEntity> {
return new Map(
this.items.map(entity => [entity.getId(), entity]),
this.items.get().map(entity => [entity.getId(), entity]),
);
}
@@ -171,13 +172,13 @@ export class CatalogEntityRegistry {
);
}
getById<T extends CatalogEntity>(id: string) {
return this.entities.get(id) as T;
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;
const entities = filtered ? this.filteredItems : this.items.get();
return entities.filter(byApiKind) as T[];
}
@@ -185,7 +186,7 @@ export class CatalogEntityRegistry {
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;
const entities = filtered ? this.filteredItems : this.items.get();
return entities.filter(byApiVersionKind) as T[];
}
@@ -245,7 +246,12 @@ export class CatalogEntityRegistry {
this.onBeforeRun(entity)
.then(doOnRun => {
if (doOnRun) {
return entity.onRun?.(catalogEntityRunContext);
return entity.onRun?.({
navigate: this.dependencies.navigate,
setCommandPaletteContext: (entity) => {
this.activeEntity = entity;
},
});
} else {
logger.debug(`onBeforeRun for ${entity.getId()} returned false`);
}
@@ -253,9 +259,3 @@ export class CatalogEntityRegistry {
.catch(error => logger.error(`[CATALOG-ENTITY-REGISTRY]: entity ${entity.getId()} onRun threw an error`, error));
}
}
export const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);
export function getActiveClusterEntity(): Cluster | undefined {
return ClusterStore.getInstance().getById(catalogEntityRegistry.activeEntity?.getId());
}
@@ -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 assert from "assert";
import hostedClusterIdInjectable from "../../common/cluster-store/hosted-cluster-id.injectable";
import type { 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);
return (query) => {
assert(hostedClusterId, "Can only create terminal APIs within a cluster frame");
return new TerminalApi({
hostedClusterId,
}, query);
};
},
});
export default createTerminalApiInjectable;
@@ -1,21 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { when } from "mobx";
import { catalogCategoryRegistry } from "../../../common/catalog";
import { catalogEntityRegistry } from "../catalog-entity-registry";
import { isActiveRoute } from "../../navigation";
import type { GeneralEntity } from "../../../common/catalog-entities";
export async function setEntityOnRouteMatch() {
await when(() => catalogEntityRegistry.entities.size > 0);
const entities: GeneralEntity[] = catalogEntityRegistry.getItemsForCategory(catalogCategoryRegistry.getByName("General"));
const activeEntity = entities.find(entity => isActiveRoute(entity.spec.path));
if (activeEntity) {
catalogEntityRegistry.activeEntity = activeEntity;
}
}
@@ -0,0 +1,52 @@
/**
* 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 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);
const activeEntity = entities.find(entity => isActiveRoute(entity.spec.path));
if (activeEntity) {
entityRegistry.activeEntity = activeEntity;
}
},
{
fireImmediately: true,
},
));
},
));
return dispose;
};
},
});
export default watchForGeneralEntityNavigationInjectable;
-18
View File
@@ -3,22 +3,4 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { JsonApiErrorParsed } from "../../common/k8s-api/json-api";
import type { Response } from "node-fetch";
import { Notifications } from "../components/notifications";
import { apiBase, apiKube } from "../../common/k8s-api";
export { apiBase, apiKube } from "../../common/k8s-api";
// Common handler for HTTP api errors
export function onApiError(error: JsonApiErrorParsed, res: Response) {
switch (res.status) {
case 403:
error.isUsedForNotification = true;
Notifications.error(error);
break;
}
}
if (apiBase) apiBase.onError.addListener(onApiError);
if (apiKube) apiKube.onError.addListener(onApiError);
+17
View File
@@ -0,0 +1,17 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { Response } from "node-fetch";
import type { JsonApiErrorParsed } from "../../common/k8s-api/json-api";
import { Notifications } from "../components/notifications";
export function onApiError(error: JsonApiErrorParsed, res: Response) {
switch (res.status) {
case 403:
error.isUsedForNotification = true;
Notifications.error(error);
break;
}
}
@@ -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 { apiBase } from "../../common/k8s-api";
import { onApiError } from "./on-api-error";
const setupOnApiErrorListenersInjectable = getInjectable({
id: "setup-on-api-error-listeners",
setup: () => {
apiBase?.onError.addListener(onApiError);
},
instantiate: () => undefined,
});
export default setupOnApiErrorListenersInjectable;
+21 -12
View File
@@ -3,7 +3,6 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getHostedClusterId } from "../utils";
import type { WebSocketEvents } from "./websocket-api";
import { WebSocketApi } from "./websocket-api";
import isEqual from "lodash/isEqual";
@@ -49,23 +48,27 @@ enum TerminalColor {
NO_COLOR = "\u001b[0m",
}
export type TerminalApiQuery = Record<string, string> & {
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 {
readonly hostedClusterId: string;
}
export class TerminalApi extends WebSocketApi<TerminalEvents> {
protected size: { width: number; height: number };
protected size?: { width: number; height: number };
@observable public isReady = false;
constructor(protected query: TerminalApiQuery) {
constructor(protected readonly dependencies: TerminalApiDependencies, protected readonly query: TerminalApiQuery) {
super({
flushOnOpen: false,
pingInterval: 30,
@@ -86,7 +89,7 @@ export class TerminalApi extends WebSocketApi<TerminalEvents> {
this.emitStatus("Connecting ...");
}
const authTokenArray = await ipcRenderer.invoke("cluster:shell-api", getHostedClusterId(), this.query.id);
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");
@@ -114,11 +117,15 @@ export class TerminalApi extends WebSocketApi<TerminalEvents> {
// data is undefined if the event that was handled is "connected"
if (data === undefined) {
/**
* Output the last line, the makes sure that the terminal isn't completely
* empty when the user refreshes.
*/
this.emit("data", window.localStorage.getItem(`${this.query.id}:last-data`));
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);
}
}
});
@@ -126,7 +133,9 @@ export class TerminalApi extends WebSocketApi<TerminalEvents> {
this.prependListener("connected", onReady);
super.connect(socketUrl);
this.socket.binaryType = "arraybuffer";
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.socket!.binaryType = "arraybuffer";
}
sendMessage(message: TerminalMessage) {
+17 -11
View File
@@ -8,6 +8,7 @@ import EventEmitter from "events";
import type TypedEventEmitter from "typed-emitter";
import type { Arguments } from "typed-emitter";
import { isDevelopment } from "../../common/vars";
import type { Defaulted } from "../utils";
interface WebsocketApiParams {
/**
@@ -62,18 +63,16 @@ export interface WebSocketEvents {
close: () => void;
}
type Defaulted<Params, DefaultParams extends keyof Params> = Required<Pick<Params, DefaultParams>> & Omit<Params, DefaultParams>;
export class WebSocketApi<Events extends WebSocketEvents> extends (EventEmitter as { new<T>(): TypedEventEmitter<T> })<Events> {
protected socket?: WebSocket | null;
protected socket: WebSocket | null = null;
protected pendingCommands: (string | ArrayBufferLike | Blob | ArrayBufferView)[] = [];
protected reconnectTimer?: any;
protected pingTimer?: any;
protected reconnectTimer?: number;
protected pingTimer?: number;
protected params: Defaulted<WebsocketApiParams, keyof typeof WebSocketApi["defaultParams"]>;
@observable readyState = WebSocketApiState.PENDING;
private static defaultParams = {
private static readonly defaultParams = {
logging: isDevelopment,
reconnectDelay: 10,
flushOnOpen: true,
@@ -83,18 +82,25 @@ export class WebSocketApi<Events extends WebSocketEvents> extends (EventEmitter
constructor(params: WebsocketApiParams) {
super();
makeObservable(this);
this.params = Object.assign({}, WebSocketApi.defaultParams, params);
this.params = {
...WebSocketApi.defaultParams,
...params,
};
const { pingInterval } = this.params;
if (pingInterval) {
this.pingTimer = setInterval(() => this.ping(), pingInterval * 1000);
this.pingTimer = window.setInterval(() => this.ping(), pingInterval * 1000);
}
}
get isConnected() {
protected getIsConnected(): this is (WebSocketApi<Events> & { socket: WebSocket }) {
return this.socket?.readyState === WebSocket.OPEN && this.isOnline;
}
get isConnected() {
return this.getIsConnected();
}
get isOnline() {
return navigator.onLine;
}
@@ -144,7 +150,7 @@ export class WebSocketApi<Events extends WebSocketEvents> extends (EventEmitter
}
send(command: string | ArrayBufferLike | Blob | ArrayBufferView) {
if (this.isConnected) {
if (this.getIsConnected()) {
this.socket.send(command);
} else {
this.pendingCommands.push(command);
@@ -186,7 +192,7 @@ export class WebSocketApi<Events extends WebSocketEvents> extends (EventEmitter
this.writeLog("will reconnect in", `${reconnectDelay}s`);
this.reconnectTimer = setTimeout(() => this.connect(url), reconnectDelay * 1000);
this.reconnectTimer = window.setTimeout(() => this.connect(url), reconnectDelay * 1000);
this.readyState = WebSocketApiState.RECONNECTING;
}
} else {
@@ -5,12 +5,16 @@
import { getInjectable } from "@ogre-tools/injectable";
import ipcRendererInjectable from "./ipc-renderer/ipc-renderer.injectable";
import { getValueFromRegisteredChannel } from "./get-value-from-registered-channel";
import type { Channel } from "../../../common/ipc-channel/channel";
export type GetValueFromRegisteredChannel = <TChannel extends Channel<TInstance>, TInstance>(channel: TChannel) => Promise<TChannel["_template"]>;
const getValueFromRegisteredChannelInjectable = getInjectable({
id: "get-value-from-registered-channel",
instantiate: (di) =>
getValueFromRegisteredChannel({ ipcRenderer: di.inject(ipcRendererInjectable) }),
instantiate: (di) => getValueFromRegisteredChannel({
ipcRenderer: di.inject(ipcRendererInjectable),
}),
});
export default getValueFromRegisteredChannelInjectable;
@@ -9,9 +9,8 @@ interface Dependencies {
ipcRenderer: IpcRenderer;
}
export const getValueFromRegisteredChannel =
({ ipcRenderer }: Dependencies) =>
export const getValueFromRegisteredChannel = ({ ipcRenderer }: Dependencies) =>
<TChannel extends Channel<TInstance>, TInstance>(
channel: TChannel,
): Promise<TChannel["_template"]> =>
ipcRenderer.invoke(channel.name);
channel: TChannel,
): Promise<TChannel["_template"]> =>
ipcRenderer.invoke(channel.name);
+24 -10
View File
@@ -36,13 +36,16 @@ import initClusterFrameInjectable from "./frames/cluster-frame/init-cluster-fram
import commandOverlayInjectable from "./components/command-palette/command-overlay.injectable";
import { Router } from "react-router";
import historyInjectable from "./navigation/history.injectable";
import themeStoreInjectable from "./theme-store.injectable";
import themeStoreInjectable from "./themes/store.injectable";
import navigateToAddClusterInjectable from "../common/front-end-routing/routes/add-cluster/navigate-to-add-cluster.injectable";
import addSyncEntriesInjectable from "./initializers/add-sync-entries.injectable";
import hotbarStoreInjectable from "../common/hotbar-store.injectable";
import hotbarStoreInjectable from "../common/hotbars/store.injectable";
import { bindEvents } from "./navigation/events";
import deleteClusterDialogModelInjectable from "./components/delete-cluster-dialog/delete-cluster-dialog-model/delete-cluster-dialog-model.injectable";
import openDeleteClusterDialogInjectable from "./components/delete-cluster-dialog/open.injectable";
import { init } from "@sentry/electron/renderer";
import kubernetesClusterCategoryInjectable from "../common/catalog/categories/kubernetes-cluster.injectable";
import autoRegistrationInjectable from "../common/k8s-api/api-manager/auto-registration.injectable";
import assert from "assert";
configurePackages(); // global packages
registerCustomThemes(); // monaco editor themes
@@ -71,6 +74,20 @@ export async function bootstrap(di: DiContainer) {
const rootElem = document.getElementById("app");
const logPrefix = `[BOOTSTRAP-${process.isMainFrame ? "ROOT" : "CLUSTER"}-FRAME]:`;
assert(rootElem, "#app MUST exist");
/**
* This is injected here to initialize it for the side effect.
*
* The side effect CANNOT be within `apiManagerInjectable` itself since that causes circular
* dependencies with the current need for legacy di use.
*
* This also MUST be done before anything else so that it can start listening for the events for
* auto initialization.
*/
di.inject(autoRegistrationInjectable);
// TODO: Remove temporal dependencies to make timing of initialization not important
di.inject(userStoreInjectable);
@@ -89,19 +106,17 @@ export async function bootstrap(di: DiContainer) {
logger.info(`${logPrefix} initializing CatalogEntityDetailRegistry`);
initializers.initCatalogEntityDetailRegistry();
const navigateToAddCluster = di.inject(navigateToAddClusterInjectable);
const addSyncEntries = di.inject(addSyncEntriesInjectable);
logger.info(`${logPrefix} initializing CatalogCategoryRegistryEntries`);
initializers.initCatalogCategoryRegistryEntries({
navigateToAddCluster,
addSyncEntries,
navigateToAddCluster: di.inject(navigateToAddClusterInjectable),
addSyncEntries: di.inject(addSyncEntriesInjectable),
kubernetesClusterCategory: di.inject(kubernetesClusterCategoryInjectable),
});
logger.info(`${logPrefix} initializing Catalog`);
initializers.initCatalog({
openCommandDialog: di.inject(commandOverlayInjectable).open,
deleteClusterDialogModel: di.inject(deleteClusterDialogModelInjectable),
openDeleteClusterDialog: di.inject(openDeleteClusterDialogInjectable),
});
const extensionLoader = di.inject(extensionLoaderInjectable);
@@ -144,7 +159,6 @@ export async function bootstrap(di: DiContainer) {
// 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);
@@ -4,8 +4,9 @@
*/
import { getInjectable } from "@ogre-tools/injectable";
import { ClusterFrameContext } from "./cluster-frame-context";
import namespaceStoreInjectable from "../components/+namespaces/namespace-store/namespace-store.injectable";
import namespaceStoreInjectable from "../components/+namespaces/store.injectable";
import hostedClusterInjectable from "../../common/cluster-store/hosted-cluster.injectable";
import assert from "assert";
const clusterFrameContextInjectable = getInjectable({
id: "cluster-frame-context",
@@ -13,13 +14,11 @@ const clusterFrameContextInjectable = getInjectable({
instantiate: (di) => {
const cluster = di.inject(hostedClusterInjectable);
return new ClusterFrameContext(
cluster,
assert(cluster, "This can only be injected within a cluster frame");
{
namespaceStore: di.inject(namespaceStoreInjectable),
},
);
return new ClusterFrameContext(cluster, {
namespaceStore: di.inject(namespaceStoreInjectable),
});
},
});
@@ -4,7 +4,7 @@
*/
import type { Cluster } from "../../common/cluster/cluster";
import type { NamespaceStore } from "../components/+namespaces/namespace-store/namespace.store";
import type { NamespaceStore } from "../components/+namespaces/store";
import type { ClusterContext } from "../../common/k8s-api/cluster-context";
import { computed, makeObservable } from "mobx";
@@ -16,7 +16,7 @@ import * as uuid from "uuid";
import { appEventBus } from "../../../common/app-event-bus/event-bus";
import { loadConfigFromString, splitConfig } from "../../../common/kube-helpers";
import { docsUrl } from "../../../common/vars";
import { iter } from "../../utils";
import { isDefined, iter } from "../../utils";
import { Button } from "../button";
import { Notifications } from "../notifications";
import { SettingLayout } from "../layout/setting-layout";
@@ -66,7 +66,7 @@ class NonInjectedAddCluster extends React.Component<Dependencies> {
return [
...this.errors,
...iter.map(this.kubeContexts.values(), ({ error }) => error),
].filter(Boolean);
].filter(isDefined);
}
readonly refreshContexts = debounce(action(() => {
@@ -106,8 +106,18 @@ class NonInjectedAddCluster extends React.Component<Dependencies> {
<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}/catalog/add-clusters/`} rel="noreferrer" target="_blank">Read more about adding clusters</a>.
{"Clusters added here are "}
<b>not</b>
{" merged into the "}
<code>~/.kube/config</code>
{" file. "}
<a
href={`${docsUrl}/catalog/add-clusters/`}
rel="noreferrer"
target="_blank"
>
Read more about adding clusters.
</a>
</p>
<div className="flex column">
<MonacoEditor
@@ -11,16 +11,16 @@ import rendererExtensionsInjectable from "../../../../extensions/renderer-extens
import { CatalogCategory } from "../../../api/catalog-entity";
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import type { AdditionalCategoryColumnRegistration, CategoryColumnRegistration } from "../custom-category-columns";
import type { CategoryColumns, GetCategoryColumnsParams } from "../get-category-columns.injectable";
import getCategoryColumnsInjectable from "../get-category-columns.injectable";
import hotbarStoreInjectable from "../../../../common/hotbar-store.injectable";
import type { CategoryColumns, GetCategoryColumnsParams } from "../columns/get.injectable";
import getCategoryColumnsInjectable from "../columns/get.injectable";
import hotbarStoreInjectable from "../../../../common/hotbars/store.injectable";
class TestCategory extends CatalogCategory {
apiVersion = "catalog.k8slens.dev/v1alpha1";
kind = "CatalogCategory";
metadata: {
name: "Test";
icon: "question_mark";
metadata = {
name: "Test",
icon: "question_mark",
};
spec: CatalogCategorySpec = {
group: "foo.bar.bat",
@@ -32,7 +32,10 @@ class TestCategory extends CatalogCategory {
constructor(columns?: CategoryColumnRegistration[]) {
super();
this.spec.displayColumns = columns;
this.spec = {
displayColumns: columns,
...this.spec,
};
}
}
@@ -54,19 +57,19 @@ describe("Custom Category Columns", () => {
});
it("should contain a kind column if activeCategory is falsy", () => {
expect(getCategoryColumns({ activeCategory: null }).renderTableHeader.find(elem => elem.title === "Kind")).toBeTruthy();
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();
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();
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();
expect(getCategoryColumns({ activeCategory: new TestCategory([]) }).renderTableHeader.find(elem => elem?.title === "Source")).toBeFalsy();
});
it("should include the displayColumns from the provided category", () => {
@@ -80,7 +83,7 @@ describe("Custom Category Columns", () => {
},
];
expect(getCategoryColumns({ activeCategory: new TestCategory(columns) }).renderTableHeader.find(elem => elem.title === "Foo")).toBeTruthy();
expect(getCategoryColumns({ activeCategory: new TestCategory(columns) }).renderTableHeader.find(elem => elem?.title === "Foo")).toBeTruthy();
});
});
@@ -117,11 +120,11 @@ describe("Custom Category Columns", () => {
});
it("should include columns from extensions that match", () => {
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem.title === "High")).toBeTruthy();
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();
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "High2")).toBeFalsy();
});
});
});
@@ -20,8 +20,8 @@ describe("Custom Category Views", () => {
});
it("should order items correctly over all extensions", () => {
const component1 = (): React.ReactElement => null;
const component2 = (): React.ReactElement => null;
const component1 = (): React.ReactNode => null;
const component2 = (): React.ReactNode => null;
di.override(rendererExtensionsInjectable, () => computed(() => [
{
@@ -51,15 +51,15 @@ describe("Custom Category Views", () => {
] as LensRendererExtension[]));
const customCategoryViews = di.inject(customCategoryViewsInjectable);
const { after } = customCategoryViews.get().get("foo").get("bar");
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.ReactElement => null;
const component2 = (): React.ReactElement => null;
const component1 = (): React.ReactNode => null;
const component2 = (): React.ReactNode => null;
di.override(rendererExtensionsInjectable, () => computed(() => [
{
@@ -89,7 +89,7 @@ describe("Custom Category Views", () => {
] as LensRendererExtension[]));
const customCategoryViews = di.inject(customCategoryViewsInjectable);
const { before } = customCategoryViews.get().get("foo").get("bar");
const { before = [] } = customCategoryViews.get().get("foo")?.get("bar") ?? {};
expect(before[0].View).toBe(component1);
});
@@ -114,18 +114,20 @@ export class CatalogAddButton extends React.Component<CatalogAddButtonProps> {
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",
}}
/>;
return (
<SpeedDialAction
key={index}
icon={<Icon material={menuItem.icon}/>}
tooltipTitle={menuItem.title}
onClick={(evt) => {
evt.stopPropagation();
menuItem.onClick();
}}
TooltipClasses={{
popper: "catalogSpeedDialPopper",
}}
/>
);
})}
</SpeedDial>
);
@@ -14,16 +14,22 @@ import { CatalogEntityDetailRegistry } from "../../../extensions/registries";
import { isDevelopment } from "../../../common/vars";
import { cssNames } from "../../utils";
import { Avatar } from "../avatar";
import { getLabelBadges } from "./helpers";
import type { GetLabelBadges } from "./get-label-badges.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import getLabelBadgesInjectable from "./get-label-badges.injectable";
export interface CatalogEntityDetailsProps<T extends CatalogEntity> {
entity: T;
export interface CatalogEntityDetailsProps<Entity extends CatalogEntity> {
entity: Entity;
hideDetails(): void;
onRun: () => void;
}
interface Dependencies {
getLabelBadges: GetLabelBadges;
}
@observer
export class CatalogEntityDetails<T extends CatalogEntity> extends Component<CatalogEntityDetailsProps<T>> {
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 />;
@@ -32,11 +38,11 @@ export class CatalogEntityDetails<T extends CatalogEntity> extends Component<Cat
}
}
renderContent(entity: T) {
const { onRun, hideDetails } = this.props;
renderContent(entity: Entity) {
const { onRun, hideDetails, getLabelBadges } = this.props;
const detailItems = CatalogEntityDetailRegistry.getInstance().getItemsForKind(entity.kind, entity.apiVersion);
const details = detailItems.map(({ components }, index) => <components.Details entity={entity} key={index} />);
const showDefaultDetails = detailItems.find((item) => item.priority > 999) === undefined;
const showDefaultDetails = detailItems.find((item) => item.priority ?? 50 > 999) === undefined;
return (
<>
@@ -109,3 +115,10 @@ export class CatalogEntityDetails<T extends CatalogEntity> extends Component<Cat
);
}
}
export const CatalogEntityDetails = withInjectables<Dependencies, CatalogEntityDetailsProps<CatalogEntity>>(NonInjectedCatalogEntityDetails, {
getProps: (di, props) => ({
...props,
getLabelBadges: di.inject(getLabelBadgesInjectable),
}),
}) as <Entity extends CatalogEntity>(props: CatalogEntityDetailsProps<Entity>) => React.ReactElement;
@@ -4,45 +4,42 @@
*/
import React from "react";
import { cssNames } from "../../utils";
import { cssNames, hasDefiniteField } from "../../utils";
import type { MenuActionsProps } from "../menu/menu-actions";
import { MenuActions } from "../menu/menu-actions";
import type { CatalogEntity, CatalogEntityContextMenuContext } from "../../api/catalog-entity";
import type { CatalogEntity, CatalogEntityContextMenu } from "../../api/catalog-entity";
import { observer } from "mobx-react";
import { makeObservable, observable } from "mobx";
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<T extends CatalogEntity> extends MenuActionsProps {
entity: T;
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> {
@observable private contextMenu: CatalogEntityContextMenuContext;
constructor(props: CatalogEntityDrawerMenuProps<T> & Dependencies) {
super(props);
makeObservable(this);
}
private readonly menuItems = observable.array<CatalogEntityContextMenu>();
componentDidMount() {
this.contextMenu = {
menuItems: [],
this.props.visitEntityContextMenu(this.props.entity, {
menuItems: this.menuItems,
navigate: this.props.navigate,
};
this.props.entity?.onContextMenuOpen(this.contextMenu);
});
}
getMenuItems(entity: T): React.ReactChild[] {
@@ -50,9 +47,9 @@ class NonInjectedCatalogEntityDrawerMenu<T extends CatalogEntity> extends React.
return [];
}
const items = this.contextMenu.menuItems
.filter(menuItem => menuItem.icon)
const items = this.menuItems
.map(this.props.normalizeMenuItem)
.filter(hasDefiniteField("icon"))
.map(menuItem => (
<MenuItem key={menuItem.title} onClick={menuItem.onClick}>
<Icon
@@ -67,8 +64,22 @@ class NonInjectedCatalogEntityDrawerMenu<T extends CatalogEntity> extends React.
<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"/>}
addContent={(
<Icon
material="push_pin"
interactive
small
tooltip="Add to Hotbar"
/>
)}
removeContent={(
<Icon
svg="push_off"
interactive
small
tooltip="Remove from Hotbar"
/>
)}
/>,
);
@@ -78,7 +89,7 @@ class NonInjectedCatalogEntityDrawerMenu<T extends CatalogEntity> extends React.
render() {
const { className, entity, ...menuProps } = this.props;
if (!this.contextMenu || !entity.isEnabled()) {
if (!this.menuItems.length || !entity.isEnabled()) {
return null;
}
@@ -97,7 +108,8 @@ class NonInjectedCatalogEntityDrawerMenu<T extends CatalogEntity> extends React.
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>) => React.ReactElement;
}) as <Entity extends CatalogEntity>(props: CatalogEntityDrawerMenuProps<Entity>) => JSX.Element;
@@ -3,14 +3,16 @@
* 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/catalog-entity-registry.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) => new CatalogEntityStore({
registry: di.inject(catalogEntityRegistryInjectable),
instantiate: (di) => catalogEntityStore({
entityRegistry: di.inject(catalogEntityRegistryInjectable),
catalogRegistry: di.inject(catalogCategoryRegistryInjectable),
}),
});
@@ -3,61 +3,81 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { computed, makeObservable, observable, reaction } from "mobx";
import type { CatalogEntityRegistry } from "../../../api/catalog-entity-registry";
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 { ItemStore } from "../../../../common/item.store";
import type { CatalogCategory } from "../../../../common/catalog";
import { catalogCategoryRegistry } from "../../../../common/catalog";
import { autoBind, disposer } from "../../../../common/utils";
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";
interface Dependencies {
registry: CatalogEntityRegistry;
entityRegistry: CatalogEntityRegistry;
catalogRegistry: CatalogCategoryRegistry;
}
export class CatalogEntityStore extends ItemStore<CatalogEntity> {
constructor(private dependencies: Dependencies) {
super();
makeObservable(this);
autoBind(this);
}
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;
};
@observable activeCategory?: CatalogCategory;
@observable selectedItemId?: string;
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();
@computed get entities() {
if (!this.activeCategory) {
return this.dependencies.registry.filteredItems;
return category
? entityRegistry.getItemsForCategory(category, { filtered: true })
: entityRegistry.filteredItems;
});
const selectedItem = computed(() => {
const id = selectedItemId.get();
if (!id) {
return undefined;
}
return this.dependencies.registry.getItemsForCategory(this.activeCategory, { filtered: true });
}
return entities.get().find(entity => entity.getId() === id);
});
const loadAll = () => {
const category = activeCategory.get();
@computed get selectedItem() {
return this.entities.find(e => e.getId() === this.selectedItemId);
}
watch() {
return disposer(
reaction(() => this.entities, () => this.loadAll()),
reaction(() => this.activeCategory, () => this.loadAll(), { delay: 100 }),
);
}
loadAll() {
if (this.activeCategory) {
this.activeCategory.emit("load");
if (category) {
category.emit("load");
} else {
for (const category of catalogCategoryRegistry.items) {
for (const category of catalogRegistry.items) {
category.emit("load");
}
}
};
// concurrency is true to fix bug if catalog filter is removed and added at the same time
return this.loadItems(() => this.entities, undefined, true);
}
onRun(entity: CatalogEntity): void {
this.dependencies.registry.onRun(entity);
}
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: () => entityRegistry.filteredItems.length,
isLoaded: true,
isSelected: (item) => item.getId() === selectedItemId.get(),
isSelectedAll: () => false,
pickOnlySelected: () => [],
toggleSelection: () => {},
toggleSelectionAll: () => {},
removeSelectedItems: async () => {},
};
}
@@ -18,7 +18,7 @@ import { observer } from "mobx-react";
import { CatalogCategoryLabel } from "./catalog-category-label";
export interface CatalogMenuProps {
activeItem: string;
activeTab: string | undefined;
onItemClick: (id: string) => void;
}
@@ -54,9 +54,14 @@ export const CatalogMenu = observer((props: CatalogMenuProps) => {
defaultExpanded={["catalog"]}
defaultCollapseIcon={<Icon material="expand_more"/>}
defaultExpandIcon={<Icon material="chevron_right" />}
selected={props.activeItem || "browse"}
selected={props.activeTab || "browse"}
>
<Item nodeId="browse" label="Browse" data-testid="*-tab" onClick={() => props.onItemClick("*")}/>
<Item
nodeId="browse"
label="Browse"
data-testid="*-tab"
onClick={() => props.onItemClick("*")}
/>
<Item
nodeId="catalog"
label={<div className={styles.parent}>Categories</div>}
@@ -12,7 +12,7 @@ const catalogPreviousActiveTabStorageInjectable = getInjectable({
instantiate: (di) => {
const createStorage = di.inject(createStorageInjectable);
return createStorage(
return createStorage<string | null>(
"catalog-previous-active-tab",
browseCatalogTab,
);
@@ -9,24 +9,25 @@ import userEvent from "@testing-library/user-event";
import { Catalog } from "./catalog";
import { mockWindow } from "../../../../__mocks__/windowMock";
import type { CatalogEntityActionContext, CatalogEntityData } from "../../../common/catalog";
import { CatalogCategoryRegistry, CatalogEntity } from "../../../common/catalog";
import { CatalogEntityRegistry } from "../../api/catalog-entity-registry";
import { CatalogEntity } from "../../../common/catalog";
import type { CatalogEntityRegistry } from "../../api/catalog/entity/registry";
import { CatalogEntityDetailRegistry } from "../../../extensions/registries";
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/catalog-entity-registry.injectable";
import catalogEntityRegistryInjectable from "../../api/catalog/entity/registry.injectable";
import type { DiRender } from "../test-utils/renderFor";
import { renderFor } from "../test-utils/renderFor";
import { ThemeStore } from "../../theme.store";
import { UserStore } from "../../../common/user-store";
import mockFs from "mock-fs";
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 appVersionInjectable from "../../../common/get-configuration-file-model/app-version/app-version.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 ipcRendererInjectable from "../../app-paths/get-value-from-registered-channel/ipc-renderer/ipc-renderer.injectable";
import { UserStore } from "../../../common/user-store";
mockWindow();
jest.mock("electron", () => ({
@@ -60,57 +61,57 @@ class MockCatalogEntity extends CatalogEntity {
constructor(data: CatalogEntityData, public onRun: (context: CatalogEntityActionContext) => void | Promise<void>) {
super(data);
}
}
public onContextMenuOpen(): void | Promise<void> {}
public onSettingsOpen(): void | Promise<void> {}
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 />", () => {
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);
}
let di: DiContainer;
let catalogEntityStore: CatalogEntityStore;
let catalogEntityRegistry: CatalogEntityRegistry;
let emitEvent: (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.permitSideEffects(getConfigurationFileModelInjectable);
di.permitSideEffects(appVersionInjectable);
await di.runSetups();
mockFs();
UserStore.createInstance();
ThemeStore.createInstance();
CatalogEntityDetailRegistry.createInstance();
render = renderFor(di);
onRun = jest.fn();
catalogEntityItem = createMockCatalogEntity(onRun);
catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable);
const catalogCategoryRegistry = new CatalogCategoryRegistry();
catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);
UserStore.createInstance(); // TODO: replace with DI
di.override(catalogEntityRegistryInjectable, () => catalogEntityRegistry);
di.override(ipcRendererInjectable, () => ({
on: jest.fn(),
invoke: jest.fn(), // TODO: replace with proper mocking via the IPC bridge
} as never));
emitEvent = jest.fn();
@@ -119,27 +120,20 @@ describe("<Catalog />", () => {
}));
catalogEntityStore = di.inject(catalogEntityStoreInjectable);
Object.assign(catalogEntityStore, {
selectedItem: computed(() => catalogEntityItem),
});
});
afterEach(() => {
UserStore.resetInstance();
ThemeStore.resetInstance();
CatalogEntityDetailRegistry.resetInstance();
UserStore.resetInstance();
jest.clearAllMocks();
jest.restoreAllMocks();
mockFs.restore();
});
it("can use catalogEntityRegistry.addOnBeforeRun to add hooks for catalog entities", (done) => {
const onRun = jest.fn();
const catalogEntityItem = createMockCatalogEntity(onRun);
// mock as if there is a selected item > the detail panel opens
jest
.spyOn(catalogEntityStore, "selectedItem", "get")
.mockImplementation(() => catalogEntityItem);
catalogEntityRegistry.addOnBeforeRun(
(event) => {
expect(event.target.getId()).toBe("a_catalogEntity_uid");
@@ -152,22 +146,12 @@ describe("<Catalog />", () => {
},
);
render(
<Catalog />,
);
render(<Catalog />);
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
});
it("onBeforeRun prevents event => onRun wont be triggered", (done) => {
const onRun = jest.fn();
const catalogEntityItem = createMockCatalogEntity(onRun);
// mock as if there is a selected item > the detail panel opens
jest
.spyOn(catalogEntityStore, "selectedItem", "get")
.mockImplementation(() => catalogEntityItem);
catalogEntityRegistry.addOnBeforeRun(
(e) => {
setTimeout(() => {
@@ -184,14 +168,6 @@ describe("<Catalog />", () => {
});
it("addOnBeforeRun throw an exception => onRun will be triggered", (done) => {
const onRun = jest.fn();
const catalogEntityItem = createMockCatalogEntity(onRun);
// mock as if there is a selected item > the detail panel opens
jest
.spyOn(catalogEntityStore, "selectedItem", "get")
.mockImplementation(() => catalogEntityItem);
catalogEntityRegistry.addOnBeforeRun(
() => {
setTimeout(() => {
@@ -209,13 +185,7 @@ describe("<Catalog />", () => {
});
it("addOnRunHook return a promise and does not prevent run event => onRun()", (done) => {
const onRun = jest.fn(() => done());
const catalogEntityItem = createMockCatalogEntity(onRun);
// mock as if there is a selected item > the detail panel opens
jest
.spyOn(catalogEntityStore, "selectedItem", "get")
.mockImplementation(() => catalogEntityItem);
onRun.mockImplementation(() => done());
catalogEntityRegistry.addOnBeforeRun(
async () => {
@@ -229,14 +199,6 @@ describe("<Catalog />", () => {
});
it("addOnRunHook return a promise and prevents event wont be triggered", (done) => {
const onRun = jest.fn();
const catalogEntityItem = createMockCatalogEntity(onRun);
// mock as if there is a selected item > the detail panel opens
jest
.spyOn(catalogEntityStore, "selectedItem", "get")
.mockImplementation(() => catalogEntityItem);
catalogEntityRegistry.addOnBeforeRun(
async (e) => {
expect(onRun).not.toBeCalled();
@@ -256,14 +218,6 @@ describe("<Catalog />", () => {
});
it("addOnRunHook return a promise and reject => onRun will be triggered", (done) => {
const onRun = jest.fn();
const catalogEntityItem = createMockCatalogEntity(onRun);
// mock as if there is a selected item > the detail panel opens
jest
.spyOn(catalogEntityStore, "selectedItem", "get")
.mockImplementation(() => catalogEntityItem);
catalogEntityRegistry.addOnBeforeRun(
async () => {
setTimeout(() => {
+91 -71
View File
@@ -12,13 +12,12 @@ 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 { CatalogEntityContextMenuContext } from "../../api/catalog-entity";
import type { HotbarStore } from "../../../common/hotbar-store";
import type { CatalogEntity } from "../../../common/catalog";
import { catalogCategoryRegistry } from "../../../common/catalog";
import type { CatalogEntityContextMenu } from "../../api/catalog-entity";
import type { CatalogCategory, CatalogCategoryRegistry, CatalogEntity } from "../../../common/catalog";
import { CatalogAddButton } from "./catalog-add-button";
import { Notifications } from "../notifications";
import { MainLayout } from "../layout/main-layout";
import type { StorageLayer } from "../../utils";
import { prevDefault } from "../../utils";
import { CatalogEntityDetails } from "./catalog-entity-details";
import { CatalogMenu } from "./catalog-menu";
@@ -29,8 +28,8 @@ 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 "./get-category-columns.injectable";
import getCategoryColumnsInjectable from "./get-category-columns.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";
@@ -40,14 +39,18 @@ import catalogRouteParametersInjectable from "./catalog-route-parameters.injecta
import { browseCatalogTab } from "./catalog-browse-tab";
import type { AppEvent } from "../../../common/app-event-bus/event-bus";
import appEventBusInjectable from "../../../common/app-event-bus/app-event-bus.injectable";
import hotbarStoreInjectable from "../../../common/hotbar-store.injectable";
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";
interface Dependencies {
catalogPreviousActiveTabStorage: { set: (value: string ) => void; get: () => string };
catalogPreviousActiveTabStorage: StorageLayer<string | null>;
catalogEntityStore: CatalogEntityStore;
getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns;
customCategoryViews: IComputedValue<Map<string, Map<string, RegisteredCustomCategoryViewDecl>>>;
@@ -58,13 +61,15 @@ interface Dependencies {
};
navigateToCatalog: NavigateToCatalog;
hotbarStore: HotbarStore;
catalogCategoryRegistry: CatalogCategoryRegistry;
visitEntityContextMenu: VisitEntityContextMenu;
navigate: Navigate;
normalizeMenuItem: NormalizeCatalogEntityContextMenu;
}
@observer
class NonInjectedCatalog extends React.Component<Dependencies> {
@observable private contextMenu: CatalogEntityContextMenuContext;
private readonly menuItems = observable.array<CatalogEntityContextMenu>();
@observable activeTab?: string;
constructor(props: Dependencies) {
@@ -93,42 +98,50 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
}
async componentDidMount() {
this.contextMenu = {
menuItems: observable.array([]),
navigate: this.props.navigate,
};
const {
catalogEntityStore,
catalogPreviousActiveTabStorage,
catalogCategoryRegistry,
} = this.props;
disposeOnUnmount(this, [
this.props.catalogEntityStore.watch(),
catalogEntityStore.watch(),
reaction(() => this.routeActiveTab, async (routeTab) => {
catalogPreviousActiveTabStorage.set(this.routeActiveTab);
try {
await when(() => (routeTab === browseCatalogTab || !!catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab)), { timeout: 5_000 }); // we need to wait because extensions might take a while to load
const item = catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab);
runInAction(() => {
this.activeTab = routeTab;
this.props.catalogEntityStore.activeCategory = item;
catalogEntityStore.activeCategory.set(item);
});
} catch (error) {
console.error(error);
Notifications.error(<p>Unknown category: {routeTab}</p>);
Notifications.error((
<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);
}
});
}
}),
]);
// If active category is filtered out, automatically switch to the first category
disposeOnUnmount(this, reaction(() => catalogCategoryRegistry.filteredItems, () => {
if (!catalogCategoryRegistry.filteredItems.find(item => item.getId() === this.props.catalogEntityStore.activeCategory.getId())) {
const item = catalogCategoryRegistry.filteredItems[0];
runInAction(() => {
if (item) {
this.activeTab = item.getId();
this.props.catalogEntityStore.activeCategory = item;
}
});
}
}));
this.props.emitEvent({
name: "catalog",
action: "open",
@@ -144,15 +157,15 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
}
onDetails = (entity: CatalogEntity) => {
if (this.props.catalogEntityStore.selectedItemId) {
this.props.catalogEntityStore.selectedItemId = null;
if (this.props.catalogEntityStore.selectedItemId.get()) {
this.props.catalogEntityStore.selectedItemId.set(undefined);
} else {
this.props.catalogEntityStore.onRun(entity);
}
};
get categories() {
return catalogCategoryRegistry.items;
return this.props.catalogCategoryRegistry.items;
}
onTabChange = action((tabId: string | null) => {
@@ -175,26 +188,25 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
}
});
renderNavigation() {
return (
<CatalogMenu activeItem={this.activeTab} onItemClick={this.onTabChange} />
);
}
renderItemMenu = (entity: CatalogEntity) => {
const onOpen = () => {
this.contextMenu.menuItems = [];
entity.onContextMenuOpen(this.contextMenu);
this.menuItems.clear();
this.props.visitEntityContextMenu(entity, {
menuItems: this.menuItems,
navigate: this.props.navigate,
});
};
return (
<MenuActions onOpen={onOpen}>
<MenuItem key="open-details" onClick={() => this.props.catalogEntityStore.selectedItemId = entity.getId()}>
<MenuItem
key="open-details"
onClick={() => this.props.catalogEntityStore.selectedItemId.set(entity.getId())}
>
View Details
</MenuItem>
{
this.contextMenu.menuItems
this.menuItems
.map(this.props.normalizeMenuItem)
.map((menuItem, index) => (
<MenuItem key={index} onClick={menuItem.onClick}>
@@ -231,7 +243,6 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
<Icon
small
className={styles.pinIcon}
material={!isItemInHotbar && "push_pin"}
svg={isItemInHotbar ? "push_off" : "push_pin"}
tooltip={isItemInHotbar ? "Remove from Hotbar" : "Add to Hotbar"}
onClick={prevDefault(() => isItemInHotbar ? this.removeFromHotbar(entity) : this.addToHotbar(entity))}
@@ -240,15 +251,12 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
);
}
renderViews = () => {
const { catalogEntityStore, customCategoryViews } = this.props;
const { activeCategory } = catalogEntityStore;
renderViews = (activeCategory: CatalogCategory | undefined) => {
if (!activeCategory) {
return this.renderList();
return this.renderList(activeCategory);
}
const customViews = customCategoryViews.get()
const customViews = this.props.customCategoryViews.get()
.get(activeCategory.spec.group)
?.get(activeCategory.spec.names.kind);
const renderView = ({ View }: CustomCategoryViewComponents, index: number) => (
@@ -261,15 +269,14 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
return (
<>
{customViews?.before.map(renderView)}
{this.renderList()}
{this.renderList(activeCategory)}
{customViews?.after.map(renderView)}
</>
);
};
renderList() {
renderList(activeCategory: CatalogCategory | undefined) {
const { catalogEntityStore, getCategoryColumns } = this.props;
const { activeCategory } = catalogEntityStore;
const tableId = activeCategory
? `catalog-items-${activeCategory.metadata.name.replace(" ", "")}`
: "catalog-items";
@@ -279,14 +286,15 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
}
return (
<ItemListLayout
<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}
getItems={() => catalogEntityStore.entities.get()}
customizeTableRowProps={entity => ({
disabled: !entity.isEnabled(),
})}
@@ -302,27 +310,37 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
return null;
}
const selectedEntity = this.props.catalogEntityStore.selectedItem;
const activeCategory = this.props.catalogEntityStore.activeCategory.get();
const selectedItem = this.props.catalogEntityStore.selectedItem.get();
return (
<MainLayout sidebar={this.renderNavigation()}>
<MainLayout
sidebar={(
<CatalogMenu
activeTab={this.activeTab}
onItemClick={this.onTabChange}
/>
)}
>
<div className={styles.views}>
{this.renderViews()}
{this.renderViews(activeCategory)}
</div>
{
selectedEntity
? <CatalogEntityDetails
entity={selectedEntity}
hideDetails={() => this.props.catalogEntityStore.selectedItemId = null}
onRun={() => this.props.catalogEntityStore.onRun(selectedEntity)}
/>
: (
<RenderDelay>
<CatalogAddButton
category={this.props.catalogEntityStore.activeCategory}
/>
</RenderDelay>
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>
);
@@ -340,6 +358,8 @@ export const Catalog = withInjectables<Dependencies>(NonInjectedCatalog, {
navigateToCatalog: di.inject(navigateToCatalogInjectable),
emitEvent: di.inject(appEventBusInjectable).emit,
hotbarStore: di.inject(hotbarStoreInjectable),
catalogCategoryRegistry: di.inject(catalogCategoryRegistryInjectable),
visitEntityContextMenu: di.inject(visitEntityContextMenuInjectable),
navigate: di.inject(navigateInjectable),
normalizeMenuItem: di.inject(normalizeCatalogEntityContextMenuInjectable),
}),
@@ -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,64 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "../catalog.module.scss";
import React from "react";
import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns";
import { KubeObject } from "../../../../common/k8s-api/kube-object";
import { getInjectable } from "@ogre-tools/injectable";
import getLabelBadgesInjectable from "../get-label-badges.injectable";
const defaultCategoryColumnsInjectable = getInjectable({
id: "default-category-columns",
instantiate: (di): RegisteredAdditionalCategoryColumn[] => {
const getLabelBadges = di.inject(getLabelBadgesInjectable);
return [
{
id: "source",
priority: 10,
renderCell: entity => entity.getSource(),
titleProps: {
title: "Source",
className: styles.sourceCell,
id: "source",
sortBy: "source",
},
sortCallback: entity => entity.getSource(),
searchFilter: entity => `source=${entity.getSource()}`,
},
{
id: "labels",
priority: 20,
renderCell: getLabelBadges,
titleProps: {
id: "labels",
title: "Labels",
className: `${styles.labelsCell} scrollable`,
},
searchFilter: entity => KubeObject.stringifyLabels(entity.metadata.labels),
},
{
id: "status",
priority: 30,
renderCell: entity => (
<span key="phase" className={entity.status.phase}>
{entity.status.phase}
</span>
),
titleProps: {
title: "Status",
className: styles.statusCell,
id: "status",
sortBy: "status",
},
searchFilter: entity => entity.status.phase,
sortCallback: entity => entity.status.phase,
},
];
},
});
export default defaultCategoryColumnsInjectable;
@@ -0,0 +1,63 @@
/**
* 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(registration.titleProps);
}
return {
sortingCallbacks,
renderTableHeader,
renderTableContents: entity => tableRowRenderers.map(fn => fn(entity)),
searchFilters,
};
};
},
});
export default getCategoryColumnsInjectable;
@@ -3,15 +3,15 @@
* 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 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/hotbar-store.injectable";
import type { HotbarStore } from "../../../common/hotbar-store";
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);
@@ -37,7 +37,6 @@ const renderEntityName = (hotbarStore: HotbarStore) => (entity: CatalogEntity) =
<Icon
small
className={styles.pinIcon}
material={!isItemInHotbar && "push_pin"}
svg={isItemInHotbar ? "push_off" : "push_pin"}
tooltip={isItemInHotbar ? "Remove from Hotbar" : "Add to Hotbar"}
onClick={onClick}
@@ -46,8 +45,7 @@ const renderEntityName = (hotbarStore: HotbarStore) => (entity: CatalogEntity) =
);
};
const nameCategoryColumnInjectable = getInjectable({
const namedCategoryColumnInjectable = getInjectable({
id: "name-category-column",
instantiate: (di): RegisteredAdditionalCategoryColumn => ({
id: "name",
@@ -64,4 +62,4 @@ const nameCategoryColumnInjectable = getInjectable({
}),
});
export default nameCategoryColumnInjectable;
export default namedCategoryColumnInjectable;
@@ -0,0 +1,43 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { CatalogCategory } from "../../../api/catalog-entity";
import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns";
import categoryColumnsInjectable from "../custom-category-columns.injectable";
import defaultCategoryColumnsInjectable from "./default-category.injectable";
import namedCategoryColumnInjectable from "./named-category.injectable";
export type GetColumnsForCategory = (activeCategory: CatalogCategory) => RegisteredAdditionalCategoryColumn[];
const getColumnsForCategoryInjectable = getInjectable({
id: "get-columns-for-category",
instantiate: (di): GetColumnsForCategory => {
const extensionColumns = di.inject(categoryColumnsInjectable);
const defaultCategoryColumns = di.inject(defaultCategoryColumnsInjectable);
const nameCategoryColumn = di.inject(namedCategoryColumnInjectable);
return (activeCategory) => {
const fromExtensions = (
extensionColumns
.get()
.get(activeCategory.spec.group)
?.get(activeCategory.spec.names.kind)
?? []
);
const fromCategory = activeCategory.spec.displayColumns?.map(({ priority = 50, ...column }) => ({
priority,
...column,
})) ?? defaultCategoryColumns;
return [
nameCategoryColumn,
...fromExtensions,
...fromCategory,
];
};
},
});
export default getColumnsForCategoryInjectable;
@@ -1,98 +0,0 @@
/**
* 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 type { CatalogCategory, CatalogEntity } from "../../../common/catalog";
import type { ItemListLayoutProps } from "../item-object-list";
import type { RegisteredAdditionalCategoryColumn } from "./custom-category-columns";
import categoryColumnsInjectable from "./custom-category-columns.injectable";
import { defaultCategoryColumns, browseAllColumns } from "./internal-category-columns";
import nameCategoryColumnInjectable from "./name-category-column.injectable";
interface Dependencies {
extensionColumns: IComputedValue<Map<string, Map<string, RegisteredAdditionalCategoryColumn[]>>>;
nameCategoryColumn: RegisteredAdditionalCategoryColumn;
}
export interface GetCategoryColumnsParams {
activeCategory: CatalogCategory | null | undefined;
}
export type CategoryColumns = Required<Pick<ItemListLayoutProps<CatalogEntity>, "sortingCallbacks" | "searchFilters" | "renderTableContents" | "renderTableHeader">>;
function getSpecificCategoryColumns(activeCategory: CatalogCategory, extensionColumns: IComputedValue<Map<string, Map<string, RegisteredAdditionalCategoryColumn[]>>>, nameCategoryColumn: RegisteredAdditionalCategoryColumn): RegisteredAdditionalCategoryColumn[] {
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,
];
}
function getBrowseAllColumns(nameCategoryColumn: RegisteredAdditionalCategoryColumn): RegisteredAdditionalCategoryColumn[] {
return [
...browseAllColumns,
nameCategoryColumn,
...defaultCategoryColumns,
];
}
const getCategoryColumns = ({ extensionColumns, nameCategoryColumn }: Dependencies) => ({ activeCategory }: GetCategoryColumnsParams): CategoryColumns => {
const allRegistrations = orderBy(
activeCategory
? getSpecificCategoryColumns(activeCategory, extensionColumns, nameCategoryColumn)
: getBrowseAllColumns(nameCategoryColumn),
"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(registration.titleProps);
}
return {
sortingCallbacks,
renderTableHeader,
renderTableContents: entity => tableRowRenderers.map(fn => fn(entity)),
searchFilters,
};
};
const getCategoryColumnsInjectable = getInjectable({
id: "get-category-columns",
instantiate: (di) => getCategoryColumns({
extensionColumns: di.inject(categoryColumnsInjectable),
nameCategoryColumn: di.inject(nameCategoryColumnInjectable),
}),
});
export default getCategoryColumnsInjectable;
@@ -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;
@@ -1,33 +0,0 @@
/**
* 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 { KubeObject } from "../../../common/k8s-api/kube-object";
import type { CatalogEntity } from "../../api/catalog-entity";
import { Badge } from "../badge";
import { searchUrlParam } from "../input";
/**
* @param entity The entity to render badge labels for
*/
export function getLabelBadges(entity: CatalogEntity, onClick?: (evt: React.MouseEvent<any, MouseEvent>) => void) {
return 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}
/>
));
}
@@ -9,17 +9,17 @@ import { MenuItem } from "../menu";
import type { CatalogEntity } from "../../api/catalog-entity";
import { withInjectables } from "@ogre-tools/injectable-react";
import hotbarStoreInjectable from "../../../common/hotbar-store.injectable";
import type { HotbarStore } from "../../../common/hotbar-store";
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;
entity: CatalogEntity;
addContent: ReactNode;
removeContent: ReactNode;
}
function NonInjectedHotbarToggleMenuItem({
@@ -31,15 +31,17 @@ function NonInjectedHotbarToggleMenuItem({
const [itemInHotbar, setItemInHotbar] = useState(hotbarStore.isAddedToActive(entity));
return (
<MenuItem onClick={() => {
if (itemInHotbar) {
hotbarStore.removeFromHotbar(entity.getId());
setItemInHotbar(false);
} else {
hotbarStore.addToHotbar(entity);
setItemInHotbar(true);
}
}}>
<MenuItem
onClick={() => {
if (itemInHotbar) {
hotbarStore.removeFromHotbar(entity.getId());
setItemInHotbar(false);
} else {
hotbarStore.addToHotbar(entity);
setItemInHotbar(true);
}
}}
>
{itemInHotbar ? removeContent : addContent }
</MenuItem>
);
@@ -1,69 +0,0 @@
/**
* 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 { getLabelBadges } from "./helpers";
import { KubeObject } from "../../../common/k8s-api/kube-object";
export const browseAllColumns: RegisteredAdditionalCategoryColumn[] = [
{
id: "kind",
priority: 5,
renderCell: entity => entity.kind,
titleProps: {
id: "kind",
sortBy: "kind",
title: "Kind",
},
sortCallback: entity => entity.kind,
},
];
export const defaultCategoryColumns: RegisteredAdditionalCategoryColumn[] = [
{
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,
},
];
@@ -11,23 +11,31 @@ import { computed, makeObservable } from "mobx";
import { Icon } from "../icon";
import { SubHeader } from "../layout/sub-header";
import { Table, TableCell, TableHead, TableRow } from "../table";
import { nodesStore } from "../+nodes/nodes.store";
import { eventStore } from "../+events/event.store";
import { cssNames, prevDefault } from "../../utils";
import type { ItemObject } from "../../../common/item.store";
import { Spinner } from "../spinner";
import { ThemeStore } from "../../theme.store";
import { kubeSelectedUrlParam, toggleDetails } from "../kube-detail-params";
import { apiManager } from "../../../common/k8s-api/api-manager";
import type { ThemeStore } from "../../themes/store";
import type { ApiManager } from "../../../common/k8s-api/api-manager";
import { KubeObjectAge } from "../kube-object/age";
import { withInjectables } from "@ogre-tools/injectable-react";
import themeStoreInjectable from "../../themes/store.injectable";
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";
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";
export interface ClusterIssuesProps {
className?: string;
}
interface IWarning extends ItemObject {
interface Warning extends ItemObject {
kind: string;
message: string;
message: string | undefined;
selfLink: string;
renderAge: () => React.ReactElement;
ageMs: number;
@@ -39,16 +47,25 @@ enum sortBy {
age = "age",
}
interface Dependencies {
themeStore: ThemeStore;
nodeStore: NodeStore;
eventStore: EventStore;
apiManager: ApiManager;
kubeSelectedUrlParam: PageParam<string>;
toggleKubeDetailsPane: ToggleKubeDetailsPane;
}
@observer
export class ClusterIssues extends React.Component<ClusterIssuesProps> {
constructor(props: ClusterIssuesProps) {
class NonInjectedClusterIssues extends React.Component<ClusterIssuesProps & Dependencies> {
constructor(props: ClusterIssuesProps & Dependencies) {
super(props);
makeObservable(this);
}
@computed get warnings(): IWarning[] {
@computed get warnings(): Warning[] {
return [
...nodesStore.items.flatMap(node => (
...this.props.nodeStore.items.flatMap(node => (
node.getWarningConditions()
.map(({ message }) => ({
selfLink: node.selfLink,
@@ -60,21 +77,27 @@ export class ClusterIssues extends React.Component<ClusterIssuesProps> {
ageMs: -node.getCreationTimestamp(),
}))
)),
...eventStore.getWarnings().map(warning => ({
...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: apiManager.lookupApiLink(warning.involvedObject, warning),
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 (
@@ -85,7 +108,7 @@ export class ClusterIssues extends React.Component<ClusterIssuesProps> {
onClick={prevDefault(() => toggleDetails(selfLink))}
>
<TableCell className={styles.message}>
{message}
{message ?? "<unknown>"}
</TableCell>
<TableCell className={styles.object}>
{getName()}
@@ -103,7 +126,7 @@ export class ClusterIssues extends React.Component<ClusterIssuesProps> {
renderContent() {
const { warnings } = this;
if (!eventStore.isLoaded) {
if (!this.props.eventStore.isLoaded) {
return (
<Spinner center/>
);
@@ -112,7 +135,12 @@ export class ClusterIssues extends React.Component<ClusterIssuesProps> {
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/>
<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>
@@ -122,7 +150,8 @@ export class ClusterIssues extends React.Component<ClusterIssuesProps> {
return (
<>
<SubHeader className={styles.SubHeader}>
<Icon material="error_outline"/> Warnings: {warnings.length}
<Icon material="error_outline"/>
{` Warnings: ${warnings.length}`}
</SubHeader>
<Table
tableId="cluster_issues"
@@ -137,7 +166,7 @@ export class ClusterIssues extends React.Component<ClusterIssuesProps> {
sortByDefault={{ sortBy: sortBy.object, orderBy: "asc" }}
sortSyncWithUrl={false}
getTableRow={this.getTableRow}
className={cssNames("box grow", ThemeStore.getInstance().activeTheme.type)}
className={cssNames("box grow", this.props.themeStore.activeTheme.type)}
>
<TableHead nowrap>
<TableCell className="message">Message</TableCell>
@@ -158,3 +187,15 @@ export class ClusterIssues extends React.Component<ClusterIssuesProps> {
);
}
}
export const ClusterIssues = withInjectables<Dependencies, ClusterIssuesProps>(NonInjectedClusterIssues, {
getProps: (di, props) => ({
...props,
themeStore: di.inject(themeStoreInjectable),
apiManager: di.inject(apiManagerInjectable),
eventStore: di.inject(eventStoreInjectable),
nodeStore: di.inject(nodeStoreInjectable),
kubeSelectedUrlParam: di.inject(kubeSelectedUrlParamInjectable),
toggleKubeDetailsPane: di.inject(toggleKubeDetailsPaneInjectable),
}),
});
@@ -5,20 +5,25 @@
import React from "react";
import { observer } from "mobx-react";
import { nodesStore } from "../+nodes/nodes.store";
import type { NodeStore } from "../+nodes/store";
import { cssNames } from "../../utils";
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";
interface Dependencies {
clusterOverviewStore: ClusterOverviewStore;
nodeStore: NodeStore;
}
const NonInjectedClusterMetricSwitchers = observer(({ clusterOverviewStore }: Dependencies) => {
const { masterNodes, workerNodes } = nodesStore;
const NonInjectedClusterMetricSwitchers = observer(({
clusterOverviewStore,
nodeStore,
}: Dependencies) => {
const { masterNodes, workerNodes } = nodeStore;
const metricsValues = clusterOverviewStore.getMetricsValues(clusterOverviewStore.metrics);
const disableRoles = !masterNodes.length || !workerNodes.length;
const disableMetrics = !metricsValues.length;
@@ -30,7 +35,7 @@ const NonInjectedClusterMetricSwitchers = observer(({ clusterOverviewStore }: De
asButtons
className={cssNames("RadioGroup flex gaps", { disabled: disableRoles })}
value={clusterOverviewStore.metricNodeRole}
onChange={(metric: MetricNodeRole) => clusterOverviewStore.metricNodeRole = metric}
onChange={metric => clusterOverviewStore.metricNodeRole = metric}
>
<Radio label="Master" value={MetricNodeRole.MASTER}/>
<Radio label="Worker" value={MetricNodeRole.WORKER}/>
@@ -41,7 +46,7 @@ const NonInjectedClusterMetricSwitchers = observer(({ clusterOverviewStore }: De
asButtons
className={cssNames("RadioGroup flex gaps", { disabled: disableMetrics })}
value={clusterOverviewStore.metricType}
onChange={(value: MetricType) => clusterOverviewStore.metricType = value}
onChange={value => clusterOverviewStore.metricType = value}
>
<Radio label="CPU" value={MetricType.CPU}/>
<Radio label="Memory" value={MetricType.MEMORY}/>
@@ -51,13 +56,10 @@ const NonInjectedClusterMetricSwitchers = observer(({ clusterOverviewStore }: De
);
});
export const ClusterMetricSwitchers = withInjectables<Dependencies>(
NonInjectedClusterMetricSwitchers,
{
getProps: (di) => ({
clusterOverviewStore: di.inject(clusterOverviewStoreInjectable),
}),
},
);
export const ClusterMetricSwitchers = withInjectables<Dependencies>(NonInjectedClusterMetricSwitchers, {
getProps: (di) => ({
clusterOverviewStore: di.inject(clusterOverviewStoreInjectable),
nodeStore: di.inject(nodeStoreInjectable),
}),
});
@@ -5,7 +5,7 @@
import styles from "./cluster-metrics.module.scss";
import React from "react";
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";
@@ -13,19 +13,19 @@ import { MetricType } from "./cluster-overview-store/cluster-overview-store";
import { BarChart } from "../chart";
import { bytesToUnits, cssNames } from "../../utils";
import { Spinner } from "../spinner";
import { ZebraStripes } from "../chart/zebra-stripes.plugin";
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";
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" };
@@ -52,9 +52,13 @@ const NonInjectedClusterMetrics = observer(({ clusterOverviewStore: { metricType
tooltips: {
callbacks: {
label: ({ index }, data) => {
const value = data.datasets[0].data[index] as ChartPoint;
if (!index) {
return "<unknown>";
}
return value.y.toString();
const value = data.datasets?.[0].data?.[index] as ChartPoint;
return value.y?.toString() ?? "<unknown>";
},
},
},
@@ -71,7 +75,11 @@ const NonInjectedClusterMetrics = observer(({ clusterOverviewStore: { metricType
tooltips: {
callbacks: {
label: ({ index }, data) => {
const value = data.datasets[0].data[index] as ChartPoint;
if (!index) {
return "<unknown>";
}
const value = data.datasets?.[0].data?.[index] as ChartPoint;
return bytesToUnits(parseInt(value.y as string), { precision: 3 });
},
@@ -96,7 +104,7 @@ const NonInjectedClusterMetrics = observer(({ clusterOverviewStore: { metricType
data={{ datasets }}
timeLabelStep={5}
showLegend={false}
plugins={[ZebraStripes]}
plugins={plugins}
className={styles.chart}
/>
);
@@ -3,40 +3,36 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type {
ClusterOverviewStorageState } from "./cluster-overview-store";
import {
ClusterOverviewStore,
MetricNodeRole,
MetricType,
} from "./cluster-overview-store";
import type { ClusterOverviewStorageState } from "./cluster-overview-store";
import { ClusterOverviewStore, MetricNodeRole, MetricType } from "./cluster-overview-store";
import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable";
import apiManagerInjectable from "../../kube-object-menu/dependencies/api-manager.injectable";
import { kubeObjectStoreInjectionToken } from "../../../../common/k8s-api/api-manager/manager.injectable";
import clusterApiInjectable from "../../../../common/k8s-api/endpoints/cluster.api.injectable";
import storesAndApisCanBeCreatedInjectable from "../../../stores-apis-can-be-created.injectable";
import assert from "assert";
import nodeStoreInjectable from "../../+nodes/store.injectable";
const clusterOverviewStoreInjectable = getInjectable({
id: "cluster-overview-store",
instantiate: (di) => {
assert(di.inject(storesAndApisCanBeCreatedInjectable), "clusterOverviewStore is only available in certain environments");
const createStorage = di.inject(createStorageInjectable);
const clusterApi = di.inject(clusterApiInjectable);
const storage = createStorage<ClusterOverviewStorageState>(
"cluster_overview",
{
metricType: MetricType.CPU, // setup defaults
metricNodeRole: MetricNodeRole.WORKER,
},
);
const store = new ClusterOverviewStore({
storage,
});
const apiManager = di.inject(apiManagerInjectable);
apiManager.registerStore(store);
return store;
return new ClusterOverviewStore({
storage: createStorage<ClusterOverviewStorageState>(
"cluster_overview",
{
metricType: MetricType.CPU, // setup defaults
metricNodeRole: MetricNodeRole.WORKER,
},
),
nodeStore: di.inject(nodeStoreInjectable),
}, clusterApi);
},
injectionToken: kubeObjectStoreInjectionToken,
});
export default clusterOverviewStoreInjectable;
@@ -5,12 +5,12 @@
import { action, observable, reaction, when, makeObservable } from "mobx";
import { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store";
import type { Cluster } from "../../../../common/k8s-api/endpoints";
import { clusterApi, getMetricsByNodeNames, type IClusterMetrics } from "../../../../common/k8s-api/endpoints";
import type { StorageHelper } from "../../../utils";
import type { Cluster, ClusterApi } from "../../../../common/k8s-api/endpoints";
import { getMetricsByNodeNames, type ClusterMetricData } from "../../../../common/k8s-api/endpoints";
import type { StorageLayer } from "../../../utils";
import { autoBind } from "../../../utils";
import { type IMetricsReqParams, normalizeMetrics } from "../../../../common/k8s-api/endpoints/metrics.api";
import { nodesStore } from "../../+nodes/nodes.store";
import type { NodeStore } from "../../+nodes/store";
export enum MetricType {
MEMORY = "memory",
@@ -27,14 +27,13 @@ export interface ClusterOverviewStorageState {
metricNodeRole: MetricNodeRole;
}
interface Dependencies {
storage: StorageHelper<ClusterOverviewStorageState>;
interface ClusterOverviewStoreDependencies {
readonly storage: StorageLayer<ClusterOverviewStorageState>;
readonly nodeStore: NodeStore;
}
export class ClusterOverviewStore extends KubeObjectStore<Cluster> implements ClusterOverviewStorageState {
api = clusterApi;
@observable metrics: Partial<IClusterMetrics> = {};
export class ClusterOverviewStore extends KubeObjectStore<Cluster, ClusterApi> implements ClusterOverviewStorageState {
@observable metrics: Partial<ClusterMetricData> = {};
@observable metricsLoaded = false;
get metricType(): MetricType {
@@ -53,8 +52,8 @@ export class ClusterOverviewStore extends KubeObjectStore<Cluster> implements Cl
this.dependencies.storage.merge({ metricNodeRole: value });
}
constructor(private dependencies: Dependencies ) {
super();
constructor(protected readonly dependencies: ClusterOverviewStoreDependencies, api: ClusterApi) {
super(api);
makeObservable(this);
autoBind(this);
@@ -71,8 +70,8 @@ export class ClusterOverviewStore extends KubeObjectStore<Cluster> implements Cl
});
// check which node type to select
reaction(() => nodesStore.items.length, () => {
const { masterNodes, workerNodes } = nodesStore;
reaction(() => this.dependencies.nodeStore.items.length, () => {
const { masterNodes, workerNodes } = this.dependencies.nodeStore;
if (!masterNodes.length) this.metricNodeRole = MetricNodeRole.WORKER;
if (!workerNodes.length) this.metricNodeRole = MetricNodeRole.MASTER;
@@ -81,15 +80,15 @@ export class ClusterOverviewStore extends KubeObjectStore<Cluster> implements Cl
@action
async loadMetrics(params?: IMetricsReqParams) {
await when(() => nodesStore.isLoaded);
const { masterNodes, workerNodes } = nodesStore;
await when(() => this.dependencies.nodeStore.isLoaded);
const { masterNodes, workerNodes } = this.dependencies.nodeStore;
const nodes = this.metricNodeRole === MetricNodeRole.MASTER && masterNodes.length ? masterNodes : workerNodes;
this.metrics = await getMetricsByNodeNames(nodes.map(node => node.getName()), params);
this.metricsLoaded = true;
}
getMetricsValues(source: Partial<IClusterMetrics>): [number, string][] {
getMetricsValues(source: Partial<ClusterMetricData>): [number, string][] {
switch (this.metricType) {
case MetricType.CPU:
return normalizeMetrics(source.cpuUsage).data.result[0].values;
@@ -8,29 +8,36 @@ import styles from "./cluster-overview.module.scss";
import React from "react";
import { reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { nodesStore } from "../+nodes/nodes.store";
import { podsStore } from "../+workloads-pods/pods.store";
import type { Disposer } from "../../utils";
import { getHostedClusterId, interval } from "../../utils";
import { TabLayout } from "../layout/tab-layout-2";
import type { NodeStore } from "../+nodes/store";
import type { PodStore } from "../+workloads-pods/store";
import { interval } from "../../utils";
import { TabLayout } from "../layout/tab-layout";
import { Spinner } from "../spinner";
import { ClusterIssues } from "./cluster-issues";
import { ClusterMetrics } from "./cluster-metrics";
import type { ClusterOverviewStore } from "./cluster-overview-store/cluster-overview-store";
import { ClusterPieCharts } from "./cluster-pie-charts";
import { getActiveClusterEntity } from "../../api/catalog-entity-registry";
import { getActiveClusterEntity } from "../../api/catalog/entity/legacy-globals";
import { ClusterMetricsResourceType } from "../../../common/cluster-types";
import { ClusterStore } from "../../../common/cluster-store/cluster-store";
import { eventStore } from "../+events/event.store";
import type { EventStore } from "../+events/store";
import { withInjectables } from "@ogre-tools/injectable-react";
import kubeWatchApiInjectable from "../../kube-watch-api/kube-watch-api.injectable";
import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import type { KubeObject } from "../../../common/k8s-api/kube-object";
import clusterOverviewStoreInjectable from "./cluster-overview-store/cluster-overview-store.injectable";
import type { SubscribeStores } from "../../kube-watch-api/kube-watch-api";
import type { Cluster } from "../../../common/cluster/cluster";
import hostedClusterInjectable from "../../../common/cluster-store/hosted-cluster.injectable";
import assert from "assert";
import subscribeStoresInjectable from "../../kube-watch-api/subscribe-stores.injectable";
import podStoreInjectable from "../+workloads-pods/store.injectable";
import eventStoreInjectable from "../+events/store.injectable";
import nodeStoreInjectable from "../+nodes/store.injectable";
interface Dependencies {
subscribeStores: (stores: KubeObjectStore<KubeObject>[]) => Disposer;
subscribeStores: SubscribeStores;
clusterOverviewStore: ClusterOverviewStore;
hostedCluster: Cluster;
podStore: PodStore;
eventStore: EventStore;
nodeStore: NodeStore;
}
@observer
@@ -38,9 +45,7 @@ class NonInjectedClusterOverview extends React.Component<Dependencies> {
private metricPoller = interval(60, () => this.loadMetrics());
loadMetrics() {
const cluster = ClusterStore.getInstance().getById(getHostedClusterId());
if (cluster.available) {
if (this.props.hostedCluster.available) {
this.props.clusterOverviewStore.loadMetrics();
}
}
@@ -50,9 +55,9 @@ class NonInjectedClusterOverview extends React.Component<Dependencies> {
disposeOnUnmount(this, [
this.props.subscribeStores([
podsStore,
eventStore,
nodesStore,
this.props.podStore,
this.props.eventStore,
this.props.nodeStore,
]),
reaction(
@@ -66,7 +71,7 @@ class NonInjectedClusterOverview extends React.Component<Dependencies> {
this.metricPoller.stop();
}
renderMetrics(isMetricsHidden: boolean) {
renderMetrics(isMetricsHidden?: boolean) {
if (isMetricsHidden) {
return null;
}
@@ -79,7 +84,7 @@ class NonInjectedClusterOverview extends React.Component<Dependencies> {
);
}
renderClusterOverview(isLoaded: boolean, isMetricsHidden: boolean) {
renderClusterOverview(isLoaded: boolean, isMetricsHidden?: boolean) {
if (!isLoaded) {
return <Spinner center/>;
}
@@ -93,7 +98,8 @@ class NonInjectedClusterOverview extends React.Component<Dependencies> {
}
render() {
const isLoaded = nodesStore.isLoaded && eventStore.isLoaded;
const { eventStore, nodeStore } = this.props;
const isLoaded = nodeStore.isLoaded && eventStore.isLoaded;
const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.Cluster);
return (
@@ -106,13 +112,19 @@ class NonInjectedClusterOverview extends React.Component<Dependencies> {
}
}
export const ClusterOverview = withInjectables<Dependencies>(
NonInjectedClusterOverview,
export const ClusterOverview = withInjectables<Dependencies>(NonInjectedClusterOverview, {
getProps: (di) => {
const hostedCluster = di.inject(hostedClusterInjectable);
{
getProps: (di) => ({
subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores,
assert(hostedCluster, "Only allowed to renderer ClusterOverview within cluster frame");
return {
subscribeStores: di.inject(subscribeStoresInjectable),
clusterOverviewStore: di.inject(clusterOverviewStoreInjectable),
}),
hostedCluster,
podStore: di.inject(podStoreInjectable),
eventStore: di.inject(eventStoreInjectable),
nodeStore: di.inject(nodeStoreInjectable),
};
},
);
});
@@ -11,15 +11,17 @@ import type { ClusterOverviewStore } from "./cluster-overview-store/cluster-over
import { MetricNodeRole } from "./cluster-overview-store/cluster-overview-store";
import { Spinner } from "../spinner";
import { Icon } from "../icon";
import { nodesStore } from "../+nodes/nodes.store";
import type { NodeStore } from "../+nodes/store";
import type { PieChartData } from "../chart";
import { PieChart } from "../chart";
import { ClusterNoMetrics } from "./cluster-no-metrics";
import { bytesToUnits, cssNames } from "../../utils";
import { ThemeStore } from "../../theme.store";
import type { ThemeStore } from "../../themes/store";
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";
import nodeStoreInjectable from "../+nodes/store.injectable";
import themeStoreInjectable from "../../themes/store.injectable";
function createLabels(rawLabelData: [string, number | undefined][]): string[] {
return rawLabelData.map(([key, value]) => `${key}: ${value?.toFixed(2) || "N/A"}`);
@@ -27,9 +29,15 @@ function createLabels(rawLabelData: [string, number | undefined][]): string[] {
interface Dependencies {
clusterOverviewStore: ClusterOverviewStore;
nodeStore: NodeStore;
themeStore: ThemeStore;
}
const NonInjectedClusterPieCharts = observer(({ clusterOverviewStore }: Dependencies) => {
const NonInjectedClusterPieCharts = observer(({
clusterOverviewStore,
nodeStore,
themeStore,
}: Dependencies) => {
const renderLimitWarning = () => {
return (
<div className="node-warning flex gaps align-center">
@@ -46,7 +54,7 @@ const NonInjectedClusterPieCharts = observer(({ clusterOverviewStore }: Dependen
const { podUsage, podAllocatableCapacity, podCapacity } = data;
const cpuLimitsOverload = cpuLimits > cpuAllocatableCapacity;
const memoryLimitsOverload = memoryLimits > memoryAllocatableCapacity;
const defaultColor = ThemeStore.getInstance().activeTheme.colors.pieChartDefaultColor;
const defaultColor = themeStore.activeTheme.colors.pieChartDefaultColor;
if (!memoryCapacity || !cpuCapacity || !podCapacity || !memoryAllocatableCapacity || !cpuAllocatableCapacity || !podAllocatableCapacity) return null;
const cpuData: PieChartData = {
@@ -210,7 +218,7 @@ const NonInjectedClusterPieCharts = observer(({ clusterOverviewStore }: Dependen
};
const renderContent = ({ metricNodeRole, metricsLoaded }: ClusterOverviewStore) => {
const { masterNodes, workerNodes } = nodesStore;
const { masterNodes, workerNodes } = nodeStore;
const nodes = metricNodeRole === MetricNodeRole.MASTER ? masterNodes : workerNodes;
if (!nodes.length) {
@@ -249,12 +257,10 @@ const NonInjectedClusterPieCharts = observer(({ clusterOverviewStore }: Dependen
);
});
export const ClusterPieCharts = withInjectables<Dependencies>(
NonInjectedClusterPieCharts,
{
getProps: (di) => ({
clusterOverviewStore: di.inject(clusterOverviewStoreInjectable),
}),
},
);
export const ClusterPieCharts = withInjectables<Dependencies>(NonInjectedClusterPieCharts, {
getProps: (di) => ({
clusterOverviewStore: di.inject(clusterOverviewStoreInjectable),
nodeStore: di.inject(nodeStoreInjectable),
themeStore: di.inject(themeStoreInjectable),
}),
});
@@ -12,51 +12,96 @@ import { DrawerItem, DrawerTitle } from "../drawer";
import { Badge } from "../badge";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import { cssNames } from "../../utils";
import type { IHpaMetric } from "../../../common/k8s-api/endpoints/hpa.api";
import { HorizontalPodAutoscaler, HpaMetricType } from "../../../common/k8s-api/endpoints/hpa.api";
import type { HorizontalPodAutoscalerMetricSpec, HorizontalPodAutoscalerMetricTarget } from "../../../common/k8s-api/endpoints/horizontal-pod-autoscaler.api";
import { HorizontalPodAutoscaler, HpaMetricType } from "../../../common/k8s-api/endpoints/horizontal-pod-autoscaler.api";
import { Table, TableCell, TableHead, TableRow } from "../table";
import { apiManager } from "../../../common/k8s-api/api-manager";
import type { ApiManager } from "../../../common/k8s-api/api-manager";
import { KubeObjectMeta } from "../kube-object-meta";
import { getDetailsUrl } from "../kube-detail-params";
import logger from "../../../common/logger";
import type { GetDetailsUrl } from "../kube-detail-params/get-details-url.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import apiManagerInjectable from "../../../common/k8s-api/api-manager/manager.injectable";
import getDetailsUrlInjectable from "../kube-detail-params/get-details-url.injectable";
export interface HpaDetailsProps extends KubeObjectDetailsProps<HorizontalPodAutoscaler> {
}
interface Dependencies {
apiManager: ApiManager;
getDetailsUrl: GetDetailsUrl;
}
@observer
export class HpaDetails extends React.Component<HpaDetailsProps> {
class NonInjectedHpaDetails extends React.Component<HpaDetailsProps & Dependencies> {
private renderTargetLink(target: HorizontalPodAutoscalerMetricTarget | undefined) {
if (!target) {
return null;
}
const { object: hpa, apiManager, getDetailsUrl } = this.props;
const { kind, name } = target;
const objectUrl = getDetailsUrl(apiManager.lookupApiLink(target, hpa));
return (
<>
on
<Link to={objectUrl}>
{`${kind}/${name}`}
</Link>
</>
);
}
renderMetrics() {
const { object: hpa } = this.props;
const renderName = (metric: IHpaMetric) => {
const renderName = (metric: HorizontalPodAutoscalerMetricSpec) => {
switch (metric.type) {
case HpaMetricType.ContainerResource:
// fallthrough
case HpaMetricType.Resource: {
const addition = metric.resource.targetAverageUtilization
const metricSpec = metric.resource ?? metric.containerResource;
const addition = metricSpec.targetAverageUtilization
? "(as a percentage of request)"
: "";
return <>Resource {metric.resource.name} on Pods {addition}</>;
}
case HpaMetricType.Pods:
return <>{metric.pods.metricName} on Pods</>;
case HpaMetricType.Object: {
const { target } = metric.object;
const { kind, name } = target;
const objectUrl = getDetailsUrl(apiManager.lookupApiLink(target, hpa));
return (
<>
{metric.object.metricName} on{" "}
<Link to={objectUrl}>{kind}/{name}</Link>
Resource
{metricSpec.name}
{" "}
on Pods
{addition}
</>
);
}
case HpaMetricType.Pods:
return (
<>
{metric.pods.metricName}
{" "}
on Pods
</>
);
case HpaMetricType.Object: {
return (
<>
{metric.object.metricName}
{" "}
{this.renderTargetLink(metric.object.target)}
</>
);
}
case HpaMetricType.External:
return (
<>
{metric.external.metricName} on{" "}
{JSON.stringify(metric.external.selector)}
{metric.external.metricName}
{" "}
on
{" "}
{JSON.stringify(metric.external.metricSelector)}
</>
);
}
@@ -82,7 +127,7 @@ export class HpaDetails extends React.Component<HpaDetailsProps> {
}
render() {
const { object: hpa } = this.props;
const { object: hpa, apiManager, getDetailsUrl } = this.props;
if (!hpa) {
return null;
@@ -103,7 +148,9 @@ export class HpaDetails extends React.Component<HpaDetailsProps> {
<DrawerItem name="Reference">
{scaleTargetRef && (
<Link to={getDetailsUrl(apiManager.lookupApiLink(scaleTargetRef, hpa))}>
{scaleTargetRef.kind}/{scaleTargetRef.name}
{scaleTargetRef.kind}
/
{scaleTargetRef.name}
</Link>
)}
</DrawerItem>
@@ -120,19 +167,20 @@ export class HpaDetails extends React.Component<HpaDetailsProps> {
{hpa.getReplicas()}
</DrawerItem>
<DrawerItem name="Status" className="status" labelsOnly>
{hpa.getConditions().map(({ type, tooltip, isReady }) => {
if (!isReady) return null;
return (
<DrawerItem
name="Status"
className="status"
labelsOnly
>
{hpa.getReadyConditions()
.map(({ type, tooltip, isReady }) => (
<Badge
key={type}
label={type}
tooltip={tooltip}
className={cssNames({ [type.toLowerCase()]: isReady })}
/>
);
})}
))}
</DrawerItem>
<DrawerTitle>Metrics</DrawerTitle>
@@ -143,3 +191,11 @@ export class HpaDetails extends React.Component<HpaDetailsProps> {
);
}
}
export const HpaDetails = withInjectables<Dependencies, HpaDetailsProps>(NonInjectedHpaDetails, {
getProps: (di, props) => ({
...props,
apiManager: di.inject(apiManagerInjectable),
getDetailsUrl: di.inject(getDetailsUrlInjectable),
}),
});
@@ -1,16 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import type { HorizontalPodAutoscaler } from "../../../common/k8s-api/endpoints/hpa.api";
import { hpaApi } from "../../../common/k8s-api/endpoints/hpa.api";
import { apiManager } from "../../../common/k8s-api/api-manager";
export class HPAStore extends KubeObjectStore<HorizontalPodAutoscaler> {
api = hpaApi;
}
export const hpaStore = new HPAStore();
apiManager.registerStore(hpaStore);
@@ -8,8 +8,8 @@ import "./hpa.scss";
import React from "react";
import { observer } from "mobx-react";
import { KubeObjectListLayout } from "../kube-object-list-layout";
import type { HorizontalPodAutoscaler } from "../../../common/k8s-api/endpoints/hpa.api";
import { hpaStore } from "./hpa.store";
import type { HorizontalPodAutoscaler } from "../../../common/k8s-api/endpoints/horizontal-pod-autoscaler.api";
import { horizontalPodAutoscalerStore } from "./legacy-store";
import { Badge } from "../badge";
import { cssNames } from "../../utils";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
@@ -38,7 +38,13 @@ export class HorizontalPodAutoscalers extends React.Component {
const metricsRemain = metrics.length > 1 ? `+${metrics.length - 1} more...` : "";
return <p>{hpa.getMetricValues(metrics[0])} {metricsRemain}</p>;
return (
<p>
{hpa.getMetricValues(metrics[0])}
{" "}
{metricsRemain}
</p>
);
}
render() {
@@ -48,7 +54,7 @@ export class HorizontalPodAutoscalers extends React.Component {
isConfigurable
tableId="configuration_hpa"
className="HorizontalPodAutoscalers"
store={hpaStore}
store={horizontalPodAutoscalerStore}
sortingCallbacks={{
[columnId.name]: hpa => hpa.getName(),
[columnId.namespace]: hpa => hpa.getNs(),
@@ -0,0 +1,12 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { asLegacyGlobalForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api";
import horizontalPodAutoscalerStoreInjectable from "./store.injectable";
/**
* @deprecated use `di.inject(horizontalPodAutoscalerStoreInjectable)` instead
*/
export const horizontalPodAutoscalerStore = asLegacyGlobalForExtensionApi(horizontalPodAutoscalerStoreInjectable);
@@ -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 assert from "assert";
import { kubeObjectStoreInjectionToken } from "../../../common/k8s-api/api-manager/manager.injectable";
import horizontalPodAutoscalerApiInjectable from "../../../common/k8s-api/endpoints/horizontal-pod-autoscaler.api.injectable";
import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable";
import { HorizontalPodAutoscalerStore } from "./store";
const horizontalPodAutoscalerStoreInjectable = getInjectable({
id: "horizontal-pod-autoscaler-store",
instantiate: (di) => {
assert(di.inject(storesAndApisCanBeCreatedInjectable), "horizontalPodAutoscalerStore is only available in certain environments");
const api = di.inject(horizontalPodAutoscalerApiInjectable);
return new HorizontalPodAutoscalerStore(api);
},
injectionToken: kubeObjectStoreInjectionToken,
});
export default horizontalPodAutoscalerStoreInjectable;
@@ -0,0 +1,10 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import type { HorizontalPodAutoscaler, HorizontalPodAutoscalerApi } from "../../../common/k8s-api/endpoints/horizontal-pod-autoscaler.api";
export class HorizontalPodAutoscalerStore extends KubeObjectStore<HorizontalPodAutoscaler, HorizontalPodAutoscalerApi> {
}
@@ -0,0 +1,12 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { asLegacyGlobalForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api";
import limitRangeStoreInjectable from "./store.injectable";
/**
* @deprecated use `di.inject(limitRangeStoreInjectable)` instead
*/
export const limitRangeStore = asLegacyGlobalForExtensionApi(limitRangeStoreInjectable);
@@ -75,27 +75,27 @@ export class LimitRangeDetails extends React.Component<LimitRangeDetailsProps> {
<div className="LimitRangeDetails">
<KubeObjectMeta object={limitRange}/>
{containerLimits.length > 0 &&
<DrawerItem name="Container Limits" labelsOnly>
{
renderLimitDetails(containerLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE])
}
</DrawerItem>
}
{podLimits.length > 0 &&
<DrawerItem name="Pod Limits" labelsOnly>
{
renderLimitDetails(podLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE])
}
</DrawerItem>
}
{pvcLimits.length > 0 &&
<DrawerItem name="Persistent Volume Claim Limits" labelsOnly>
{
renderLimitDetails(pvcLimits, [Resource.STORAGE])
}
</DrawerItem>
}
{containerLimits.length > 0 && (
<DrawerItem name="Container Limits" labelsOnly>
{
renderLimitDetails(containerLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE])
}
</DrawerItem>
)}
{podLimits.length > 0 && (
<DrawerItem name="Pod Limits" labelsOnly>
{
renderLimitDetails(podLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE])
}
</DrawerItem>
)}
{pvcLimits.length > 0 && (
<DrawerItem name="Persistent Volume Claim Limits" labelsOnly>
{
renderLimitDetails(pvcLimits, [Resource.STORAGE])
}
</DrawerItem>
)}
</div>
);
}
@@ -1,16 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import { apiManager } from "../../../common/k8s-api/api-manager";
import type { LimitRange } from "../../../common/k8s-api/endpoints/limit-range.api";
import { limitRangeApi } from "../../../common/k8s-api/endpoints/limit-range.api";
export class LimitRangesStore extends KubeObjectStore<LimitRange> {
api = limitRangeApi;
}
export const limitRangeStore = new LimitRangesStore();
apiManager.registerStore(limitRangeStore);
@@ -7,7 +7,7 @@ import "./limit-ranges.scss";
import { observer } from "mobx-react";
import { KubeObjectListLayout } from "../kube-object-list-layout";
import { limitRangeStore } from "./limit-ranges.store";
import { limitRangeStore } from "./legacy-store";
import React from "react";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import { SiblingsInTabLayout } from "../layout/siblings-in-tab-layout";
@@ -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 assert from "assert";
import { kubeObjectStoreInjectionToken } from "../../../common/k8s-api/api-manager/manager.injectable";
import limitRangeApiInjectable from "../../../common/k8s-api/endpoints/limit-range.api.injectable";
import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable";
import { LimitRangeStore } from "./store";
const limitRangeStoreInjectable = getInjectable({
id: "limit-range-store",
instantiate: (di) => {
assert(di.inject(storesAndApisCanBeCreatedInjectable), "limitRangeStore is only available in certain environments");
const api = di.inject(limitRangeApiInjectable);
return new LimitRangeStore(api);
},
injectionToken: kubeObjectStoreInjectionToken,
});
export default limitRangeStoreInjectable;
@@ -0,0 +1,10 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import type { LimitRange, LimitRangeApi } from "../../../common/k8s-api/endpoints/limit-range.api";
export class LimitRangeStore extends KubeObjectStore<LimitRange, LimitRangeApi> {
}
@@ -12,7 +12,7 @@ import { DrawerTitle } from "../drawer";
import { Notifications } from "../notifications";
import { Input } from "../input";
import { Button } from "../button";
import { configMapsStore } from "./config-maps.store";
import { configMapStore } from "./legacy-store";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import { ConfigMap } from "../../../common/k8s-api/endpoints";
import { KubeObjectMeta } from "../kube-object-meta";
@@ -24,7 +24,7 @@ export interface ConfigMapDetailsProps extends KubeObjectDetailsProps<ConfigMap>
@observer
export class ConfigMapDetails extends React.Component<ConfigMapDetailsProps> {
@observable isSaving = false;
@observable data = observable.map<string, string>();
@observable data = observable.map<string, string | undefined>();
constructor(props: ConfigMapDetailsProps) {
super(props);
@@ -48,15 +48,17 @@ export class ConfigMapDetails extends React.Component<ConfigMapDetailsProps> {
try {
this.isSaving = true;
await configMapsStore.update(configMap, {
await configMapStore.update(configMap, {
...configMap,
data: Object.fromEntries(this.data),
});
Notifications.ok(
Notifications.ok((
<p>
<>ConfigMap <b>{configMap.getName()}</b> successfully updated.</>
</p>,
);
{"ConfigMap "}
<b>{configMap.getName()}</b>
{" successfully updated."}
</p>
));
} catch (error) {
Notifications.error(`Failed to save config map: ${error}`);
} finally {
@@ -104,7 +106,8 @@ export class ConfigMapDetails extends React.Component<ConfigMapDetailsProps> {
}
<Button
primary
label="Save" waiting={this.isSaving}
label="Save"
waiting={this.isSaving}
className="save-btn"
onClick={this.save}
/>
@@ -1,16 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import type { ConfigMap } from "../../../common/k8s-api/endpoints/configmap.api";
import { configMapApi } from "../../../common/k8s-api/endpoints/configmap.api";
import { apiManager } from "../../../common/k8s-api/api-manager";
export class ConfigMapsStore extends KubeObjectStore<ConfigMap> {
api = configMapApi;
}
export const configMapsStore = new ConfigMapsStore();
apiManager.registerStore(configMapsStore);
@@ -7,7 +7,7 @@ import "./config-maps.scss";
import React from "react";
import { observer } from "mobx-react";
import { configMapsStore } from "./config-maps.store";
import { configMapStore } from "./legacy-store";
import { KubeObjectListLayout } from "../kube-object-list-layout";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import { SiblingsInTabLayout } from "../layout/siblings-in-tab-layout";
@@ -29,7 +29,7 @@ export class ConfigMaps extends React.Component {
isConfigurable
tableId="configuration_configmaps"
className="ConfigMaps"
store={configMapsStore}
store={configMapStore}
sortingCallbacks={{
[columnId.name]: configMap => configMap.getName(),
[columnId.namespace]: configMap => configMap.getNs(),
@@ -0,0 +1,12 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { asLegacyGlobalForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api";
import configMapStoreInjectable from "./store.injectable";
/**
* @deprecated use `di.inject(configMapStoreInjectable)` instead
*/
export const configMapStore = asLegacyGlobalForExtensionApi(configMapStoreInjectable);
@@ -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 assert from "assert";
import { kubeObjectStoreInjectionToken } from "../../../common/k8s-api/api-manager/manager.injectable";
import configMapApiInjectable from "../../../common/k8s-api/endpoints/config-map.api.injectable";
import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable";
import { ConfigMapStore } from "./store";
const configMapStoreInjectable = getInjectable({
id: "config-map-store",
instantiate: (di) => {
assert(di.inject(storesAndApisCanBeCreatedInjectable), "configMapStore is only available in certain environments");
const api = di.inject(configMapApiInjectable);
return new ConfigMapStore(api);
},
injectionToken: kubeObjectStoreInjectionToken,
});
export default configMapStoreInjectable;
@@ -0,0 +1,10 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import type { ConfigMap, ConfigMapApi, ConfigMapData } from "../../../common/k8s-api/endpoints/config-map.api";
export class ConfigMapStore extends KubeObjectStore<ConfigMap, ConfigMapApi, ConfigMapData> {
}
@@ -0,0 +1,12 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { asLegacyGlobalForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api";
import podDisruptionBudgetStoreInjectable from "./store.injectable";
/**
* @deprecated use `di.inject(podDisruptionBudgetStoreInjectable)` instead
*/
export const podDisruptionBudgetStore = asLegacyGlobalForExtensionApi(podDisruptionBudgetStoreInjectable);
@@ -39,13 +39,13 @@ export class PodDisruptionBudgetDetails extends React.Component<PodDisruptionBud
<div className="PdbDetails">
<KubeObjectMeta object={pdb}/>
{selectors.length > 0 &&
{selectors.length > 0 && (
<DrawerItem name="Selector" labelsOnly>
{
selectors.map(label => <Badge key={label} label={label}/>)
}
</DrawerItem>
}
)}
<DrawerItem name="Min Available">
{pdb.getMinAvailable()}
@@ -1,16 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import type { PodDisruptionBudget } from "../../../common/k8s-api/endpoints/poddisruptionbudget.api";
import { pdbApi } from "../../../common/k8s-api/endpoints/poddisruptionbudget.api";
import { apiManager } from "../../../common/k8s-api/api-manager";
export class PodDisruptionBudgetsStore extends KubeObjectStore<PodDisruptionBudget> {
api = pdbApi;
}
export const podDisruptionBudgetsStore = new PodDisruptionBudgetsStore();
apiManager.registerStore(podDisruptionBudgetsStore);
@@ -7,8 +7,8 @@ import "./pod-disruption-budgets.scss";
import * as React from "react";
import { observer } from "mobx-react";
import { podDisruptionBudgetsStore } from "./pod-disruption-budgets.store";
import type { PodDisruptionBudget } from "../../../common/k8s-api/endpoints/poddisruptionbudget.api";
import { podDisruptionBudgetStore } from "./legacy-store";
import type { PodDisruptionBudget } from "../../../common/k8s-api/endpoints/pod-disruption-budget.api";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import { KubeObjectListLayout } from "../kube-object-list-layout";
@@ -37,7 +37,7 @@ export class PodDisruptionBudgets extends React.Component<PodDisruptionBudgetsPr
isConfigurable
tableId="configuration_distribution_budgets"
className="PodDisruptionBudgets"
store={podDisruptionBudgetsStore}
store={podDisruptionBudgetStore}
sortingCallbacks={{
[columnId.name]: pdb => pdb.getName(),
[columnId.namespace]: pdb => pdb.getNs(),
@@ -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 assert from "assert";
import { kubeObjectStoreInjectionToken } from "../../../common/k8s-api/api-manager/manager.injectable";
import podDisruptionBudgetApiInjectable from "../../../common/k8s-api/endpoints/pod-disruption-budget.api.injectable";
import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable";
import { PodDisruptionBudgetStore } from "./store";
const podDisruptionBudgetStoreInjectable = getInjectable({
id: "pod-disruption-budget-store",
instantiate: (di) => {
assert(di.inject(storesAndApisCanBeCreatedInjectable), "podDisruptionBudgetStore is only available in certain environments");
const api = di.inject(podDisruptionBudgetApiInjectable);
return new PodDisruptionBudgetStore(api);
},
injectionToken: kubeObjectStoreInjectionToken,
});
export default podDisruptionBudgetStoreInjectable;
@@ -0,0 +1,10 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import type { PodDisruptionBudget, PodDisruptionBudgetApi } from "../../../common/k8s-api/endpoints/pod-disruption-budget.api";
export class PodDisruptionBudgetStore extends KubeObjectStore<PodDisruptionBudget, PodDisruptionBudgetApi> {
}
@@ -13,8 +13,8 @@ import { Dialog } from "../dialog";
import { Wizard, WizardStep } from "../wizard";
import { Input } from "../input";
import { systemName } from "../input/input_validators";
import type { IResourceQuotaValues } from "../../../common/k8s-api/endpoints/resource-quota.api";
import { resourceQuotaApi } from "../../../common/k8s-api/endpoints/resource-quota.api";
import type { IResourceQuotaValues } from "../../../common/k8s-api/endpoints";
import { resourceQuotaApi } from "../../../common/k8s-api/endpoints";
import { Select } from "../select";
import { Icon } from "../icon";
import { Button } from "../button";
@@ -55,9 +55,9 @@ export class AddQuotaDialog extends React.Component<AddQuotaDialogProps> {
public defaultNamespace = "default";
@observable quotaName = "";
@observable quotaSelectValue = "";
@observable quotaSelectValue: string | null = null;
@observable quotaInputValue = "";
@observable namespace = this.defaultNamespace;
@observable namespace: string | null = this.defaultNamespace;
@observable quotas = AddQuotaDialog.defaultQuotas;
constructor(props: AddQuotaDialogProps) {
@@ -75,21 +75,23 @@ export class AddQuotaDialog extends React.Component<AddQuotaDialogProps> {
@computed get quotaEntries() {
return Object.entries(this.quotas)
.filter(([, value]) => !!value.trim());
.filter(([, value]) => !!value?.trim());
}
@computed get quotaOptions() {
return Object.keys(this.quotas).map(quota => {
const isCompute = quota.endsWith(".cpu") || quota.endsWith(".memory");
const isStorage = quota.endsWith(".storage") || quota === "persistentvolumeclaims";
const isCount = quota.startsWith("count/");
const icon = isCompute ? "memory" : isStorage ? "storage" : isCount ? "looks_one" : "";
private getQuotaOptionLabelIconMaterial(quota: string) {
if (quota.endsWith(".cpu") || quota.endsWith(".memory")) {
return "memory";
}
return {
label: icon ? <span className="nobr"><Icon material={icon} /> {quota}</span> : quota,
value: quota,
};
});
if (quota.endsWith(".storage") || quota === "persistentvolumeclaims") {
return "storage";
}
if (quota.startsWith("count/")) {
return "looks_one";
}
return undefined;
}
setQuota = () => {
@@ -111,13 +113,14 @@ export class AddQuotaDialog extends React.Component<AddQuotaDialogProps> {
};
addQuota = async () => {
try {
const { quotaName, namespace } = this;
const quotas = this.quotaEntries.reduce<IResourceQuotaValues>((quotas, [name, value]) => {
quotas[name] = value;
const { quotaName, namespace } = this;
return quotas;
}, {});
if (!quotaName || !namespace) {
return;
}
try {
const quotas = Object.fromEntries(this.quotaEntries);
await resourceQuotaApi.create({ namespace, name: quotaName }, {
spec: {
@@ -126,7 +129,7 @@ export class AddQuotaDialog extends React.Component<AddQuotaDialogProps> {
});
this.close();
} catch (err) {
Notifications.error(err);
Notifications.checkedError(err, "Unknown error occured while creating ResourceQuota");
}
};
@@ -153,17 +156,19 @@ export class AddQuotaDialog extends React.Component<AddQuotaDialogProps> {
<Wizard header={header} done={this.close}>
<WizardStep
contentClass="flex gaps column"
disabledNext={!this.namespace}
disabledNext={!this.namespace || !this.quotaName}
nextLabel="Create"
next={this.addQuota}
>
<div className="flex gaps">
<Input
required autoFocus
required
autoFocus
placeholder="ResourceQuota name"
trim
validators={systemName}
value={this.quotaName} onChange={v => this.quotaName = v.toLowerCase()}
value={this.quotaName}
onChange={v => this.quotaName = v.toLowerCase()}
className="box grow"
/>
</div>
@@ -175,7 +180,7 @@ export class AddQuotaDialog extends React.Component<AddQuotaDialogProps> {
placeholder="Namespace"
themeName="light"
className="box grow"
onChange={({ value }) => this.namespace = value}
onChange={option => this.namespace = option?.value ?? null}
/>
<SubTitle title="Values" />
@@ -185,9 +190,25 @@ export class AddQuotaDialog extends React.Component<AddQuotaDialogProps> {
className="quota-select"
themeName="light"
placeholder="Select a quota.."
options={this.quotaOptions}
options={Object.keys(this.quotas).map(quota => ({
value: quota,
label: quota,
}))}
value={this.quotaSelectValue}
onChange={({ value }) => this.quotaSelectValue = value}
onChange={option => this.quotaSelectValue = option?.value ?? null}
formatOptionLabel={({ value }) => {
const iconMaterial = this.getQuotaOptionLabelIconMaterial(value);
return iconMaterial
? (
<span className="nobr">
<Icon material={iconMaterial} />
{" "}
{value}
</span>
)
: value;
}}
/>
<Input
maxLength={10}
@@ -197,23 +218,25 @@ export class AddQuotaDialog extends React.Component<AddQuotaDialogProps> {
onKeyDown={this.onInputQuota}
className="box grow"
/>
<Button round primary onClick={this.setQuota}>
<Button
round
primary
onClick={this.setQuota}
>
<Icon
material={this.quotas[this.quotaSelectValue] ? "edit" : "add"}
material={this.quotaSelectValue && this.quotas[this.quotaSelectValue] ? "edit" : "add"}
tooltip="Set quota"
/>
</Button>
</div>
<div className="quota-entries">
{this.quotaEntries.map(([quota, value]) => {
return (
<div key={quota} className="quota gaps inline align-center">
<div className="name">{quota}</div>
<div className="value">{value}</div>
<Icon material="clear" onClick={() => this.quotas[quota] = ""} />
</div>
);
})}
{this.quotaEntries.map(([quota, value]) => (
<div key={quota} className="quota gaps inline align-center">
<div className="name">{quota}</div>
<div className="value">{value}</div>
<Icon material="clear" onClick={() => this.quotas[quota] = ""} />
</div>
))}
</div>
</WizardStep>
</Wizard>
@@ -0,0 +1,12 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { asLegacyGlobalForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api";
import resourceQuotaStoreInjectable from "./store.injectable";
/**
* @deprecated use `di.inject(resourceQuotaStoreInjectable)` instead
*/
export const resourceQuotaStore = asLegacyGlobalForExtensionApi(resourceQuotaStoreInjectable);
@@ -8,7 +8,7 @@ import React from "react";
import kebabCase from "lodash/kebabCase";
import { observer } from "mobx-react";
import { DrawerItem, DrawerTitle } from "../drawer";
import { cpuUnitsToNumber, cssNames, unitsToBytes, metricUnitsToNumber } from "../../utils";
import { cpuUnitsToNumber, cssNames, unitsToBytes, metricUnitsToNumber, object, hasDefinedTupleValue } from "../../utils";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import { ResourceQuota } from "../../../common/k8s-api/endpoints/resource-quota.api";
import { LineProgress } from "../line-progress";
@@ -32,25 +32,29 @@ function transformUnit(name: string, value: string): number {
}
function renderQuotas(quota: ResourceQuota): JSX.Element[] {
const { hard = {}, used = {}} = quota.status;
const { hard = {}, used = {}} = quota.status ?? {};
return Object.entries(hard)
.filter(([name]) => used[name])
return object.entries(hard)
.filter(hasDefinedTupleValue)
.map(([name, value]) => {
const current = transformUnit(name, used[name]);
const current = transformUnit(name, value);
const max = transformUnit(name, value);
const usage = max === 0 ? 100 : Math.ceil(current / max * 100); // special case 0 max as always 100% usage
return (
<div key={name} className={cssNames("param", kebabCase(name))}>
<span className="title">{name}</span>
<span className="value">{used[name]} / {value}</span>
<span className="value">
{`${used[name]} / ${value}`}
</span>
<LineProgress
max={max}
value={current}
tooltip={
<p>Set: {value}. Usage: {`${usage}%`}</p>
}
tooltip={(
<p>
{`Set: ${value}. Usage: ${usage}%`}
</p>
)}
/>
</div>
);
@@ -1,16 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import type { ResourceQuota } from "../../../common/k8s-api/endpoints/resource-quota.api";
import { resourceQuotaApi } from "../../../common/k8s-api/endpoints/resource-quota.api";
import { apiManager } from "../../../common/k8s-api/api-manager";
export class ResourceQuotasStore extends KubeObjectStore<ResourceQuota> {
api = resourceQuotaApi;
}
export const resourceQuotaStore = new ResourceQuotasStore();
apiManager.registerStore(resourceQuotaStore);
@@ -9,7 +9,7 @@ import React from "react";
import { observer } from "mobx-react";
import { KubeObjectListLayout } from "../kube-object-list-layout";
import { AddQuotaDialog } from "./add-quota-dialog";
import { resourceQuotaStore } from "./resource-quotas.store";
import { resourceQuotaStore } from "./legacy-store";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import { SiblingsInTabLayout } from "../layout/siblings-in-tab-layout";
import { KubeObjectAge } from "../kube-object/age";
@@ -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 assert from "assert";
import { kubeObjectStoreInjectionToken } from "../../../common/k8s-api/api-manager/manager.injectable";
import resourceQuotaApiInjectable from "../../../common/k8s-api/endpoints/resource-quota.api.injectable";
import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable";
import { ResourceQuotaStore } from "./store";
const resourceQuotaStoreInjectable = getInjectable({
id: "resource-quota-store",
instantiate: (di) => {
assert(di.inject(storesAndApisCanBeCreatedInjectable), "resourceQuotaStore is only available in certain environments");
const api = di.inject(resourceQuotaApiInjectable);
return new ResourceQuotaStore(api);
},
injectionToken: kubeObjectStoreInjectionToken,
});
export default resourceQuotaStoreInjectable;
@@ -0,0 +1,10 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import type { ResourceQuota, ResourceQuotaApi } from "../../../common/k8s-api/endpoints/resource-quota.api";
export class ResourceQuotaStore extends KubeObjectStore<ResourceQuota, ResourceQuotaApi> {
}
@@ -8,7 +8,9 @@ import { render } from "@testing-library/react";
import { SecretDetails } from "../secret-details";
import { Secret, SecretType } from "../../../../common/k8s-api/endpoints";
jest.mock("../../kube-object-meta/kube-object-meta");
jest.mock("../../kube-object-meta/kube-object-meta", () => ({
KubeObjectMeta: () => null,
}));
describe("SecretDetails tests", () => {
@@ -20,6 +22,8 @@ describe("SecretDetails tests", () => {
name: "test",
resourceVersion: "1",
uid: "uid",
namespace: "default",
selfLink: "/api/v1/secrets/default/test",
},
data: {
foobar: "",
@@ -13,36 +13,34 @@ import { Dialog } from "../dialog";
import { Wizard, WizardStep } from "../wizard";
import { Input } from "../input";
import { systemName } from "../input/input_validators";
import type { Secret } from "../../../common/k8s-api/endpoints";
import { secretsApi, SecretType } from "../../../common/k8s-api/endpoints";
import { reverseSecretTypeMap, secretApi, SecretType } from "../../../common/k8s-api/endpoints";
import { SubTitle } from "../layout/sub-title";
import { NamespaceSelect } from "../+namespaces/namespace-select";
import type { SelectOption } from "../select";
import { Select } from "../select";
import { Icon } from "../icon";
import type { KubeObjectMetadata } from "../../../common/k8s-api/kube-object";
import { base64 } from "../../utils";
import { base64, iter } from "../../utils";
import { Notifications } from "../notifications";
import upperFirst from "lodash/upperFirst";
import { showDetails } from "../kube-detail-params";
import { fromEntries } from "../../../common/utils/objects";
export interface AddSecretDialogProps extends Partial<DialogProps> {
}
interface ISecretTemplateField {
interface SecretTemplateField {
key: string;
value?: string;
required?: boolean;
}
interface ISecretTemplate {
[field: string]: ISecretTemplateField[];
annotations?: ISecretTemplateField[];
labels?: ISecretTemplateField[];
data?: ISecretTemplateField[];
interface SecretTemplate {
[field: string]: SecretTemplateField[] | undefined;
annotations?: SecretTemplateField[];
labels?: SecretTemplateField[];
data?: SecretTemplateField[];
}
type ISecretField = keyof ISecretTemplate;
type ISecretField = keyof SecretTemplate;
const dialogState = observable.object({
isOpen: false,
@@ -63,7 +61,7 @@ export class AddSecretDialog extends React.Component<AddSecretDialogProps> {
dialogState.isOpen = false;
}
private secretTemplate: { [p: string]: ISecretTemplate } = {
private secretTemplate: Partial<Record<SecretType, SecretTemplate>> = {
[SecretType.Opaque]: {},
[SecretType.ServiceAccountToken]: {
annotations: [
@@ -73,10 +71,6 @@ export class AddSecretDialog extends React.Component<AddSecretDialogProps> {
},
};
get types() {
return Object.keys(this.secretTemplate) as SecretType[];
}
@observable secret = this.secretTemplate;
@observable name = "";
@observable namespace = "default";
@@ -91,61 +85,59 @@ export class AddSecretDialog extends React.Component<AddSecretDialogProps> {
AddSecretDialog.close();
};
private getDataFromFields = (fields: ISecretTemplateField[] = [], processValue?: (val: string) => string) => {
return fields.reduce<any>((data, field) => {
const { key, value } = field;
if (key) {
data[key] = processValue ? processValue(value) : value;
}
return data;
}, {});
private getDataFromFields = (fields: SecretTemplateField[] = [], processValue: (val: string) => string = (val => val)) => {
return iter.pipeline(fields.values())
.filterMap(({ key, value }) => (
value
? [key, processValue(value)] as const
: undefined
))
.collect(fromEntries);
};
createSecret = async () => {
const { name, namespace, type } = this;
const { data = [], labels = [], annotations = [] } = this.secret[type];
const secret: Partial<Secret> = {
type,
data: this.getDataFromFields(data, val => val ? base64.encode(val) : ""),
metadata: {
name,
namespace,
annotations: this.getDataFromFields(annotations),
labels: this.getDataFromFields(labels),
} as KubeObjectMetadata,
};
const { data = [], labels = [], annotations = [] } = this.secret[type] ?? {};
try {
const newSecret = await secretsApi.create({ namespace, name }, secret);
const newSecret = await secretApi.create({ namespace, name }, {
type,
data: this.getDataFromFields(data, val => val ? base64.encode(val) : ""),
metadata: {
name,
namespace,
annotations: this.getDataFromFields(annotations),
labels: this.getDataFromFields(labels),
},
});
showDetails(newSecret.selfLink);
showDetails(newSecret?.selfLink);
this.close();
} catch (err) {
Notifications.error(err);
Notifications.checkedError(err, "Unknown error occured while creating a Secret");
}
};
addField = (field: ISecretField) => {
const fields = this.secret[this.type][field] || [];
private getFields(field: ISecretField) {
return (this.secret[this.type] ??= {})[field] ??= [];
}
fields.push({ key: "", value: "" });
this.secret[this.type][field] = fields;
addField = (field: ISecretField) => {
this.getFields(field).push({ key: "", value: "" });
};
removeField = (field: ISecretField, index: number) => {
const fields = this.secret[this.type][field] || [];
fields.splice(index, 1);
this.getFields(field).splice(index, 1);
};
renderFields(field: ISecretField) {
const fields = this.secret[this.type][field] || [];
return (
<>
<SubTitle compact className="fields-title" title={upperFirst(field.toString())}>
<SubTitle
compact
className="fields-title"
title={upperFirst(field.toString())}
>
<Icon
small
tooltip="Add field"
@@ -154,37 +146,37 @@ export class AddSecretDialog extends React.Component<AddSecretDialogProps> {
/>
</SubTitle>
<div className="secret-fields">
{fields.map((item, index) => {
const { key = "", value = "", required } = item;
return (
{this.getFields(field)
.map((item, index) => (
<div key={index} className="secret-field flex gaps auto align-center">
<Input
className="key"
placeholder="Name"
title={key}
tabIndex={required ? -1 : 0}
readOnly={required}
value={key} onChange={v => item.key = v}
title={item.key}
tabIndex={item.required ? -1 : 0}
readOnly={item.required}
value={item.key}
onChange={v => item.key = v}
/>
<Input
multiLine maxRows={5}
required={required}
multiLine
maxRows={5}
required={item.required}
className="value"
placeholder="Value"
value={value} onChange={v => item.value = v}
value={item.value}
onChange={v => item.value = v}
/>
<Icon
small
disabled={required}
tooltip={required ? "Required field" : "Remove field"}
disabled={item.required}
tooltip={item.required ? "Required field" : "Remove field"}
className="remove-icon"
material="remove_circle_outline"
onClick={() => this.removeField(field, index)}
/>
</div>
);
})}
))}
</div>
</>
);
@@ -204,15 +196,21 @@ export class AddSecretDialog extends React.Component<AddSecretDialogProps> {
close={this.close}
>
<Wizard header={header} done={this.close}>
<WizardStep contentClass="flow column" nextLabel="Create" next={this.createSecret}>
<WizardStep
contentClass="flow column"
nextLabel="Create"
next={this.createSecret}
>
<div className="secret-name">
<SubTitle title="Secret name" />
<Input
autoFocus required
autoFocus
required
placeholder="Name"
trim
validators={systemName}
value={name} onChange={v => this.name = v}
value={name}
onChange={v => this.name = v}
/>
</div>
<div className="flex auto gaps">
@@ -222,7 +220,7 @@ export class AddSecretDialog extends React.Component<AddSecretDialogProps> {
id="secret-namespace-input"
themeName="light"
value={namespace}
onChange={({ value }) => this.namespace = value}
onChange={option => this.namespace = option?.value ?? "default"}
/>
</div>
<div className="secret-type">
@@ -230,8 +228,10 @@ export class AddSecretDialog extends React.Component<AddSecretDialogProps> {
<Select
id="secret-input"
themeName="light"
options={this.types}
value={type} onChange={({ value }: SelectOption) => this.type = value}
options={Object.keys(this.secretTemplate) as SecretType[]}
value={type}
onChange={option => this.type = option?.value ?? SecretType.Opaque}
getOptionLabel={option => reverseSecretTypeMap[option.value]}
/>
</div>
</div>
@@ -0,0 +1,12 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { asLegacyGlobalForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-object-for-extension-api";
import secretStoreInjectable from "./store.injectable";
/**
* @deprecated use `di.inject(secretStoreInjectable)` instead
*/
export const secretStore = asLegacyGlobalForExtensionApi(secretStoreInjectable);
@@ -14,7 +14,7 @@ import { Button } from "../button";
import { Notifications } from "../notifications";
import { base64, toggle } from "../../utils";
import { Icon } from "../icon";
import { secretsStore } from "./secrets.store";
import { secretStore } from "./legacy-store";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import { Secret } from "../../../common/k8s-api/endpoints";
import { KubeObjectMeta } from "../kube-object-meta";
@@ -26,7 +26,7 @@ export interface SecretDetailsProps extends KubeObjectDetailsProps<Secret> {
@observer
export class SecretDetails extends React.Component<SecretDetailsProps> {
@observable isSaving = false;
@observable data: { [name: string]: string } = {};
@observable data: Partial<Record<string, string>> = {};
@observable revealSecret = observable.set<string>();
constructor(props: SecretDetailsProps) {
@@ -53,10 +53,10 @@ export class SecretDetails extends React.Component<SecretDetailsProps> {
this.isSaving = true;
try {
await secretsStore.update(secret, { ...secret, data: this.data });
await secretStore.update(secret, { ...secret, data: this.data });
Notifications.ok("Secret successfully updated.");
} catch (err) {
Notifications.error(err);
Notifications.checkedError(err, "Unknown error occured while updating the secret");
}
this.isSaving = false;
};
@@ -65,11 +65,11 @@ export class SecretDetails extends React.Component<SecretDetailsProps> {
this.data[name] = encoded ? value : base64.encode(value);
};
renderSecret = ([name, value]: [string, string]) => {
renderSecret = ([name, value]: [string, string | undefined]) => {
let decodedVal: string | undefined;
try {
decodedVal = base64.decode(value);
decodedVal = value ? base64.decode(value) : undefined;
} catch {
/**
* The value failed to be decoded, so don't show the visibility
@@ -85,7 +85,11 @@ export class SecretDetails extends React.Component<SecretDetailsProps> {
}
return (
<div key={name} className="data" data-testid={`${name}-secret-entry`}>
<div
key={name}
className="data"
data-testid={`${name}-secret-entry`}
>
<div className="name">{name}</div>
<div className="flex gaps align-center">
<Input
@@ -120,7 +124,8 @@ export class SecretDetails extends React.Component<SecretDetailsProps> {
{secrets.map(this.renderSecret)}
<Button
primary
label="Save" waiting={this.isSaving}
label="Save"
waiting={this.isSaving}
className="save-btn"
onClick={this.saveSecret}
/>
@@ -1,16 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import type { Secret } from "../../../common/k8s-api/endpoints";
import { secretsApi } from "../../../common/k8s-api/endpoints";
import { apiManager } from "../../../common/k8s-api/api-manager";
export class SecretsStore extends KubeObjectStore<Secret> {
api = secretsApi;
}
export const secretsStore = new SecretsStore();
apiManager.registerStore(secretsStore);
@@ -10,7 +10,7 @@ import { observer } from "mobx-react";
import { AddSecretDialog } from "./add-secret-dialog";
import { KubeObjectListLayout } from "../kube-object-list-layout";
import { Badge } from "../badge";
import { secretsStore } from "./secrets.store";
import { secretStore } from "./legacy-store";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import { SiblingsInTabLayout } from "../layout/siblings-in-tab-layout";
import { KubeObjectAge } from "../kube-object/age";
@@ -33,7 +33,7 @@ export class Secrets extends React.Component {
isConfigurable
tableId="configuration_secrets"
className="Secrets"
store={secretsStore}
store={secretStore}
sortingCallbacks={{
[columnId.name]: secret => secret.getName(),
[columnId.namespace]: secret => secret.getNs(),
@@ -60,7 +60,14 @@ export class Secrets extends React.Component {
secret.getName(),
<KubeObjectStatusIcon key="icon" object={secret} />,
secret.getNs(),
secret.getLabels().map(label => <Badge scrollable key={label} label={label} expandable={false}/>),
secret.getLabels().map(label => (
<Badge
scrollable
key={label}
label={label}
expandable={false}
/>
)),
secret.getKeys().join(", "),
secret.type,
<KubeObjectAge key="age" object={secret} />,
@@ -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 assert from "assert";
import { kubeObjectStoreInjectionToken } from "../../../common/k8s-api/api-manager/manager.injectable";
import secretApiInjectable from "../../../common/k8s-api/endpoints/secret.api.injectable";
import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable";
import { SecretStore } from "./store";
const secretStoreInjectable = getInjectable({
id: "secret-store",
instantiate: (di) => {
assert(di.inject(storesAndApisCanBeCreatedInjectable), "secretStore is only available in certain environments");
const api = di.inject(secretApiInjectable);
return new SecretStore(api);
},
injectionToken: kubeObjectStoreInjectionToken,
});
export default secretStoreInjectable;
@@ -0,0 +1,10 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import type { Secret, SecretApi, SecretData } from "../../../common/k8s-api/endpoints";
export class SecretStore extends KubeObjectStore<Secret, SecretApi, SecretData> {
}
@@ -8,7 +8,7 @@ import "./crd-details.scss";
import React from "react";
import { Link } from "react-router-dom";
import { observer } from "mobx-react";
import { CustomResourceDefinition } from "../../../common/k8s-api/endpoints/crd.api";
import { CustomResourceDefinition } from "../../../common/k8s-api/endpoints/custom-resource-definition.api";
import { Badge } from "../badge";
import { DrawerItem, DrawerTitle } from "../drawer";
import type { KubeObjectDetailsProps } from "../kube-object-details";
@@ -70,7 +70,11 @@ export class CRDDetails extends React.Component<CRDDetailsProps> {
readOnly
/>
</DrawerItem>
<DrawerItem name="Conditions" className="conditions" labelsOnly>
<DrawerItem
name="Conditions"
className="conditions"
labelsOnly
>
{
crd.getConditions().map(condition => {
const { type, message, lastTransitionTime, status } = condition;
@@ -84,7 +88,10 @@ export class CRDDetails extends React.Component<CRDDetailsProps> {
tooltip={(
<>
<p>{message}</p>
<p>Last transition time: {lastTransitionTime}</p>
<p>
Last transition time:
{lastTransitionTime}
</p>
</>
)}
/>
@@ -107,7 +114,7 @@ export class CRDDetails extends React.Component<CRDDetailsProps> {
<TableCell>{listKind}</TableCell>
</TableRow>
</Table>
{printerColumns.length > 0 &&
{printerColumns.length > 0 && (
<>
<DrawerTitle>Additional Printer Columns</DrawerTitle>
<Table selectable className="printer-columns box grow">
@@ -133,8 +140,8 @@ export class CRDDetails extends React.Component<CRDDetailsProps> {
}
</Table>
</>
}
{validation &&
)}
{validation && (
<>
<DrawerTitle>Validation</DrawerTitle>
<MonacoEditor
@@ -143,7 +150,7 @@ export class CRDDetails extends React.Component<CRDDetailsProps> {
style={{ height: 400 }}
/>
</>
}
)}
</div>
);
}
@@ -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 createPageParamInjectable from "../../navigation/create-page-param.injectable";
const crdGroupsUrlParamInjectable = getInjectable({
id: "crd-groups-url-param",
instantiate: (di) => {
const createPageParam = di.inject(createPageParamInjectable);
return createPageParam<string[]>({
name: "groups",
defaultValue: [],
});
},
});
export default crdGroupsUrlParamInjectable;
@@ -6,23 +6,21 @@
import "./crd-list.scss";
import React from "react";
import { computed, makeObservable } from "mobx";
import { computed, makeObservable, observable } from "mobx";
import { observer } from "mobx-react";
import { Link } from "react-router-dom";
import { stopPropagation } from "../../utils";
import { iter, stopPropagation } from "../../utils";
import { KubeObjectListLayout } from "../kube-object-list-layout";
import { crdStore } from "./crd.store";
import type { SelectOption } from "../select";
import { customResourceDefinitionStore } from "./legacy-store";
import { Select } from "../select";
import { createPageParam } from "../../navigation";
import { Icon } from "../icon";
import { KubeObjectAge } from "../kube-object/age";
import { TabLayout } from "../layout/tab-layout-2";
export const crdGroupsUrlParam = createPageParam<string[]>({
name: "groups",
defaultValue: [],
});
import type { PageParam } from "../../navigation";
import type { CustomResourceDefinitionStore } from "./definition.store";
import { withInjectables } from "@ogre-tools/injectable-react";
import crdGroupsUrlParamInjectable from "./crd-groups-url-param.injectable";
import customResourceDefinitionStoreInjectable from "./definition.store.injectable";
enum columnId {
kind = "kind",
@@ -32,49 +30,67 @@ enum columnId {
age = "age",
}
interface Dependencies {
crdGroupsUrlParam: PageParam<string[]>;
customResourceDefinitionStore: CustomResourceDefinitionStore;
}
@observer
export class CustomResourceDefinitions extends React.Component {
constructor(props: {}) {
class NonInjectedCustomResourceDefinitions extends React.Component<Dependencies> {
private readonly selectedGroups = observable.set(this.props.crdGroupsUrlParam.get());
constructor(props: Dependencies) {
super(props);
makeObservable(this);
}
get selectedGroups(): string[] {
return crdGroupsUrlParam.get();
}
@computed get items() {
if (this.selectedGroups.length) {
return crdStore.items.filter(item => this.selectedGroups.includes(item.getGroup()));
if (this.selectedGroups.size) {
return this.props.customResourceDefinitionStore.items.filter(item => this.selectedGroups.has(item.getGroup()));
}
return crdStore.items; // show all by default
return this.props.customResourceDefinitionStore.items; // show all by default
}
toggleSelection(group: string) {
const groups = new Set(crdGroupsUrlParam.get());
@computed get groupSelectOptions() {
return Object.keys(this.props.customResourceDefinitionStore.groups)
.map(group => ({
value: group,
label: group,
isSelected: this.selectedGroups.has(group),
}));
}
if (groups.has(group)) {
groups.delete(group);
} else {
groups.add(group);
toggleSelection = (options: readonly ({ value: string })[]) => {
const groups = options.map(({ value }) => value);
this.selectedGroups.replace(groups);
this.props.crdGroupsUrlParam.set(groups);
};
private getPlaceholder() {
if (this.selectedGroups.size === 0) {
return "All groups";
}
crdGroupsUrlParam.set([...groups]);
const prefix = this.selectedGroups.size === 1
? "Group"
: "Groups";
return `${prefix}: ${iter.join(this.selectedGroups.values(), ", ")}`;
}
render() {
const { items, selectedGroups } = this;
return (
<TabLayout>
<KubeObjectListLayout
isConfigurable
tableId="crd"
className="CrdList"
store={crdStore}
// Don't subscribe the `crdStore` because <Sidebar> already has and is always mounted
store={customResourceDefinitionStore}
// Don't subscribe the `customResourceDefinitionStore` because <Sidebar> already has and is always mounted
subscribeStores={false}
items={items}
items={this.items}
sortingCallbacks={{
[columnId.kind]: crd => crd.getResourceKind(),
[columnId.group]: crd => crd.getGroup(),
@@ -90,42 +106,38 @@ export class CustomResourceDefinitions extends React.Component {
crd => -crd.getCreationTimestamp(),
]}
renderHeaderTitle="Custom Resources"
customizeHeader={({ filters, ...headerPlaceholders }) => {
let placeholder = <>All groups</>;
if (selectedGroups.length == 1) placeholder = <>Group: {selectedGroups[0]}</>;
if (selectedGroups.length >= 2) placeholder = <>Groups: {selectedGroups.join(", ")}</>;
return {
// todo: move to global filters
filters: (
<>
{filters}
<Select
id="crd-input"
className="group-select"
placeholder={placeholder}
options={Object.keys(crdStore.groups)}
onChange={({ value: group }: SelectOption) => this.toggleSelection(group)}
closeMenuOnSelect={false}
controlShouldRenderValue={false}
formatOptionLabel={({ value: group }: SelectOption) => {
const isSelected = selectedGroups.includes(group);
return (
<div className="flex gaps align-center">
<Icon small material="folder"/>
<span>{group}</span>
{isSelected && <Icon small material="check" className="box right"/>}
</div>
);
}}
/>
</>
),
...headerPlaceholders,
};
}}
customizeHeader={({ filters, ...headerPlaceholders }) => ({
// todo: move to global filters
filters: (
<>
{filters}
<Select
id="crd-input"
className="group-select"
placeholder={this.getPlaceholder()}
options={this.groupSelectOptions}
onChange={this.toggleSelection}
closeMenuOnSelect={false}
controlShouldRenderValue={false}
isMulti={true}
formatOptionLabel={({ value, isSelected }) => (
<div className="flex gaps align-center">
<Icon small material="folder" />
<span>{value}</span>
{isSelected && (
<Icon
small
material="check"
className="box right"
/>
)}
</div>
)}
/>
</>
),
...headerPlaceholders,
})}
renderTableHeader={[
{ title: "Resource", className: "kind", sortBy: columnId.kind, id: columnId.kind },
{ title: "Group", className: "group", sortBy: columnId.group, id: columnId.group },
@@ -134,7 +146,11 @@ export class CustomResourceDefinitions extends React.Component {
{ title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
]}
renderTableContents={crd => [
<Link key="link" to={crd.getResourceUrl()} onClick={stopPropagation}>
<Link
key="link"
to={crd.getResourceUrl()}
onClick={stopPropagation}
>
{crd.getResourceKind()}
</Link>,
crd.getGroup(),
@@ -147,3 +163,11 @@ export class CustomResourceDefinitions extends React.Component {
);
}
}
export const CustomResourceDefinitions = withInjectables<Dependencies>(NonInjectedCustomResourceDefinitions, {
getProps: (di, props) => ({
...props,
crdGroupsUrlParam: di.inject(crdGroupsUrlParamInjectable),
customResourceDefinitionStore: di.inject(customResourceDefinitionStoreInjectable),
}),
});
@@ -13,10 +13,10 @@ import { DrawerItem } from "../drawer";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import { KubeObjectMeta } from "../kube-object-meta";
import { Input } from "../input";
import type { AdditionalPrinterColumnsV1 } from "../../../common/k8s-api/endpoints/crd.api";
import { CustomResourceDefinition } from "../../../common/k8s-api/endpoints/crd.api";
import type { AdditionalPrinterColumnsV1 } from "../../../common/k8s-api/endpoints/custom-resource-definition.api";
import { CustomResourceDefinition } from "../../../common/k8s-api/endpoints/custom-resource-definition.api";
import { convertKubectlJsonPathToNodeJsonPath } from "../../utils/jsonPath";
import type { KubeObjectMetadata, KubeObjectStatus } from "../../../common/k8s-api/kube-object";
import type { KubeObjectStatus } from "../../../common/k8s-api/kube-object";
import { KubeObject } from "../../../common/k8s-api/kube-object";
import logger from "../../../common/logger";
import { JSONPath } from "@astronautlabs/jsonpath";
@@ -57,25 +57,34 @@ function convertSpecValue(value: any): any {
export class CustomResourceDetails extends React.Component<CustomResourceDetailsProps> {
renderAdditionalColumns(resource: KubeObject, columns: AdditionalPrinterColumnsV1[]) {
return columns.map(({ name, jsonPath }) => (
<DrawerItem key={name} name={name} renderBoolean>
<DrawerItem
key={name}
name={name}
renderBoolean
>
{convertSpecValue(JSONPath.query(resource, convertKubectlJsonPathToNodeJsonPath(jsonPath)))}
</DrawerItem>
));
}
renderStatus(customResource: KubeObject<KubeObjectMetadata, KubeObjectStatus, any>, columns: AdditionalPrinterColumnsV1[]) {
renderStatus(customResource: KubeObject<KubeObjectStatus, unknown>, columns: AdditionalPrinterColumnsV1[]) {
const showStatus = !columns.find(column => column.name == "Status") && Array.isArray(customResource.status?.conditions);
if (!showStatus) {
return null;
}
const conditions = customResource.status.conditions
.filter(({ type, reason }) => type || reason)
.map(({ type, reason, message, status }) => ({ kind: type || reason, message, status }))
const conditions = customResource.status?.conditions
?.filter(({ type, reason }) => type || reason)
.map(({ type, reason, message, status }) => ({
kind: type || reason || "<unknown>",
message,
status,
}))
.map(({ kind, message, status }, index) => (
<Badge
key={kind + index} label={kind}
key={kind + index}
label={kind}
disabled={status === "False"}
className={kind.toLowerCase()}
tooltip={message}
@@ -83,7 +92,11 @@ export class CustomResourceDetails extends React.Component<CustomResourceDetails
));
return (
<DrawerItem name="Status" className="status" labelsOnly>
<DrawerItem
name="Status"
className="status"
labelsOnly
>
{conditions}
</DrawerItem>
);
@@ -1,14 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { KubeApi } from "../../../common/k8s-api/kube-api";
import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import type { KubeObject } from "../../../common/k8s-api/kube-object";
export class CRDResourceStore<K extends KubeObject> extends KubeObjectStore<K> {
constructor(api: KubeApi<K>) {
super(api);
}
}
@@ -10,13 +10,15 @@ import { observer } from "mobx-react";
import { KubeObjectListLayout } from "../kube-object-list-layout";
import type { IComputedValue } from "mobx";
import { computed, makeObservable } from "mobx";
import { crdStore } from "./crd.store";
import { apiManager } from "../../../common/k8s-api/api-manager";
import type { ApiManager } from "../../../common/k8s-api/api-manager";
import { safeJSONPathValue } from "../../utils/jsonPath";
import { TabLayout } from "../layout/tab-layout-2";
import { withInjectables } from "@ogre-tools/injectable-react";
import customResourcesRouteParametersInjectable from "./custom-resources-route-parameters.injectable";
import { KubeObjectAge } from "../kube-object/age";
import type { CustomResourceDefinitionStore } from "./definition.store";
import apiManagerInjectable from "../../../common/k8s-api/api-manager/manager.injectable";
import customResourceDefinitionStoreInjectable from "./definition.store.injectable";
enum columnId {
name = "name",
@@ -27,27 +29,29 @@ enum columnId {
interface Dependencies {
group: IComputedValue<string>;
name: IComputedValue<string>;
apiManager: ApiManager;
customResourceDefinitionStore: CustomResourceDefinitionStore;
}
@observer
class NonInjectedCrdResources extends React.Component<Dependencies> {
class NonInjectedCustomResources extends React.Component<Dependencies> {
constructor(props: Dependencies) {
super(props);
makeObservable(this);
}
@computed get crd() {
return crdStore.getByGroup(this.props.group.get(), this.props.name.get());
return this.props.customResourceDefinitionStore.getByGroup(this.props.group.get(), this.props.name.get());
}
@computed get store() {
return apiManager.getStore(this.crd?.getResourceApiBase());
return this.props.apiManager.getStore(this.crd?.getResourceApiBase());
}
render() {
const { crd, store } = this;
if (!crd) {
if (!crd || !store) {
return null;
}
@@ -85,7 +89,9 @@ class NonInjectedCrdResources extends React.Component<Dependencies> {
})}
renderTableHeader={[
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
isNamespaced && { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
isNamespaced
? { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }
: undefined,
...extraColumns.map(({ name }) => ({
title: name,
className: name.toLowerCase(),
@@ -102,9 +108,13 @@ class NonInjectedCrdResources extends React.Component<Dependencies> {
]}
failedToLoadMessage={(
<>
<p>Failed to load {crd.getPluralName()}</p>
<p>
{`Failed to load ${crd.getPluralName()}`}
</p>
{!version.served && (
<p>Prefered version ({crd.getGroup()}/{version.name}) is not served</p>
<p>
{`Prefered version (${crd.getGroup()}/${version.name}) is not served`}
</p>
)}
</>
)}
@@ -114,18 +124,11 @@ class NonInjectedCrdResources extends React.Component<Dependencies> {
}
}
export const CrdResources = withInjectables<Dependencies>(
NonInjectedCrdResources,
{
getProps: (di) => {
const routeParameters = di.inject(customResourcesRouteParametersInjectable);
return {
group: routeParameters.group,
name: routeParameters.name,
};
},
},
);
export const CustomResources = withInjectables<Dependencies>(NonInjectedCustomResources, {
getProps: (di) => ({
...di.inject(customResourcesRouteParametersInjectable),
apiManager: di.inject(apiManagerInjectable),
customResourceDefinitionStore: di.inject(customResourceDefinitionStoreInjectable),
}),
});
@@ -1,77 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { computed, reaction, makeObservable } from "mobx";
import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import { autoBind } from "../../utils";
import type { CustomResourceDefinition } from "../../../common/k8s-api/endpoints/crd.api";
import { crdApi } from "../../../common/k8s-api/endpoints/crd.api";
import { apiManager } from "../../../common/k8s-api/api-manager";
import { KubeApi } from "../../../common/k8s-api/kube-api";
import { CRDResourceStore } from "./crd-resource.store";
import { KubeObject } from "../../../common/k8s-api/kube-object";
function initStore(crd: CustomResourceDefinition) {
const objectConstructor = class extends KubeObject {
static readonly kind = crd.getResourceKind();
static readonly namespaced = crd.isNamespaced();
static readonly apiBase = crd.getResourceApiBase();
};
const api = apiManager.getApi(objectConstructor.apiBase)
?? new KubeApi({ objectConstructor });
if (!apiManager.getStore(api)) {
apiManager.registerStore(new CRDResourceStore(api));
}
}
export class CRDStore extends KubeObjectStore<CustomResourceDefinition> {
api = crdApi;
constructor() {
super();
makeObservable(this);
autoBind(this);
// auto-init stores for crd-s
reaction(() => this.getItems(), items => items.forEach(initStore));
}
protected sortItems(items: CustomResourceDefinition[]) {
return super.sortItems(items, [
crd => crd.getGroup(),
crd => crd.getName(),
]);
}
@computed get groups() {
const groups: Record<string, CustomResourceDefinition[]> = {};
for (const crd of this.items) {
(groups[crd.getGroup()] ??= []).push(crd);
}
return groups;
}
getByGroup(group: string, pluralName: string) {
return this.groups[group]?.find(crd => crd.getPluralName() === pluralName);
}
getByObject(obj: KubeObject) {
if (!obj) return null;
const { kind, apiVersion } = obj;
return this.items.find(crd => (
kind === crd.getResourceKind() && apiVersion === `${crd.getGroup()}/${crd.getVersion()}`
));
}
}
export const crdStore = new CRDStore();
apiManager.registerStore(crdStore);
@@ -3,17 +3,12 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { noop, some } from "lodash/fp";
import { noop } from "lodash/fp";
import { computed } from "mobx";
import type {
SidebarItemRegistration } from "../layout/sidebar-items.injectable";
import {
sidebarItemsInjectionToken,
} from "../layout/sidebar-items.injectable";
import type { SidebarItemRegistration } from "../layout/sidebar-items.injectable";
import { sidebarItemsInjectionToken } from "../layout/sidebar-items.injectable";
import { Icon } from "../icon";
import React from "react";
import crdListRouteInjectable from "../../../common/front-end-routing/routes/cluster/custom-resources/crd-list/crd-list-route.injectable";
import sidebarItemsForDefinitionGroupsInjectable from "./sidebar-items-for-definition-groups.injectable";
import routeIsActiveInjectable from "../../routes/route-is-active.injectable";
@@ -26,10 +21,7 @@ const customResourceSidebarItemsInjectable = getInjectable({
const navigateToCrdList = di.inject(navigateToCrdListInjectable);
const crdListRoute = di.inject(crdListRouteInjectable);
const crdListRouteIsActive = di.inject(routeIsActiveInjectable, crdListRoute);
const definitionGroupSidebarItems = di.inject(
sidebarItemsForDefinitionGroupsInjectable,
);
const definitionGroupSidebarItems = di.inject(sidebarItemsForDefinitionGroupsInjectable);
return computed((): SidebarItemRegistration[] => {
const definitionsItem = {
@@ -42,12 +34,10 @@ const customResourceSidebarItemsInjectable = getInjectable({
orderNumber: 10,
};
const definitionGroupItems = definitionGroupSidebarItems.get();
const childrenAndGrandChildren = [
const childrenAndGrandChildren = computed(() => [
definitionsItem,
...definitionGroupItems,
];
...definitionGroupSidebarItems.get(),
]);
const parentItem: SidebarItemRegistration = {
id: "custom-resources",
@@ -55,11 +45,11 @@ const customResourceSidebarItemsInjectable = getInjectable({
title: "Custom Resources",
getIcon: () => <Icon material="extension" />,
onClick: noop,
isVisible: computed(() => some(item => item.isVisible.get(), childrenAndGrandChildren)),
isVisible: computed(() => childrenAndGrandChildren.get().some(item => item.isVisible?.get())),
orderNumber: 110,
};
return [parentItem, definitionsItem, ...definitionGroupItems];
return [parentItem, definitionsItem, ...definitionGroupSidebarItems.get()];
});
},
@@ -3,7 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { CrdResources } from "./crd-resources";
import { CustomResources } from "./crd-resources";
import customResourcesRouteInjectable from "../../../common/front-end-routing/routes/cluster/custom-resources/custom-resources/custom-resources-route.injectable";
import { routeSpecificComponentInjectionToken } from "../../routes/route-specific-component-injection-token";
@@ -12,7 +12,7 @@ const customResourcesRouteComponentInjectable = getInjectable({
instantiate: (di) => ({
route: di.inject(customResourcesRouteInjectable),
Component: CrdResources,
Component: CustomResources,
}),
injectionToken: routeSpecificComponentInjectionToken,
Loaded 100 of 1062 files, more files were not shown because too many files have changed in this diff. Show more