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

@@ -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,
@@ -5,23 +5,26 @@
import { getInjectable } from "@ogre-tools/injectable";
import { computed } from "mobx";
import { crdStore } from "./crd.store";
import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable";
import subscribeStoresInjectable from "../../kube-watch-api/subscribe-stores.injectable";
import currentlyInClusterFrameInjectable from "../../routes/currently-in-cluster-frame.injectable";
import customResourceDefinitionStoreInjectable from "./definition.store.injectable";
const customResourceDefinitionsInjectable = getInjectable({
id: "custom-resource-definitions",
instantiate: (di) => {
const currentlyInClusterFrame = di.inject(currentlyInClusterFrameInjectable);
const createStoresAndApis = di.inject(storesAndApisCanBeCreatedInjectable);
if (currentlyInClusterFrame) {
const subscribeStores = di.inject(subscribeStoresInjectable);
subscribeStores([crdStore]);
if (!createStoresAndApis) {
return computed(() => []);
}
return computed(() => [...crdStore.items]);
const store = di.inject(customResourceDefinitionStoreInjectable);
const subscribeStores = di.inject(subscribeStoresInjectable);
subscribeStores([store]);
return computed(() => [...store.items]);
},
});
@@ -0,0 +1,27 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import assert from "assert";
import autoRegistrationEmitterInjectable from "../../../common/k8s-api/api-manager/auto-registration-emitter.injectable";
import { kubeObjectStoreInjectionToken } from "../../../common/k8s-api/api-manager/manager.injectable";
import customResourceDefinitionApiInjectable from "../../../common/k8s-api/endpoints/custom-resource-definition.api.injectable";
import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable";
import { CustomResourceDefinitionStore } from "./definition.store";
const customResourceDefinitionStoreInjectable = getInjectable({
id: "custom-resource-definition-store",
instantiate: (di) => {
assert(di.inject(storesAndApisCanBeCreatedInjectable), "customResourceDefinitionStore is only available in certain environments");
const api = di.inject(customResourceDefinitionApiInjectable);
return new CustomResourceDefinitionStore({
autoRegistration: di.inject(autoRegistrationEmitterInjectable),
}, api);
},
injectionToken: kubeObjectStoreInjectionToken,
});
export default customResourceDefinitionStoreInjectable;
@@ -0,0 +1,68 @@
/**
* 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 type { KubeObjectStoreOptions } from "../../../common/k8s-api/kube-object.store";
import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import { autoBind } from "../../utils";
import type { CustomResourceDefinition, CustomResourceDefinitionApi } from "../../../common/k8s-api/endpoints/custom-resource-definition.api";
import type { KubeObject } from "../../../common/k8s-api/kube-object";
import type TypedEventEmitter from "typed-emitter";
import type { LegacyAutoRegistration } from "../../../common/k8s-api/api-manager/auto-registration-emitter.injectable";
export interface CustomResourceDefinitionStoreDependencies {
readonly autoRegistration: TypedEventEmitter<LegacyAutoRegistration>;
}
export class CustomResourceDefinitionStore extends KubeObjectStore<CustomResourceDefinition, CustomResourceDefinitionApi> {
constructor(
protected readonly dependencies: CustomResourceDefinitionStoreDependencies,
api: CustomResourceDefinitionApi,
opts?: KubeObjectStoreOptions,
) {
super(api, opts);
makeObservable(this);
autoBind(this);
reaction(
() => this.getItems(),
crds => {
for (const crd of crds) {
this.dependencies.autoRegistration.emit("customResourceDefinition", crd);
}
},
);
}
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()}`
));
}
}
@@ -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 customResourceDefinitionStoreInjectable from "./definition.store.injectable";
/**
* @deprecated use `di.inject(customResourceDefinitionStoreInjectable)` instead
*/
export const customResourceDefinitionStore = asLegacyGlobalForExtensionApi(customResourceDefinitionStoreInjectable);
@@ -9,10 +9,9 @@ import React from "react";
import type { IComputedValue } from "mobx";
import { observable, makeObservable, computed } from "mobx";
import { observer } from "mobx-react";
import { navigation } from "../../navigation";
import { Tabs, Tab } from "../tabs";
import type { CatalogEntity } from "../../api/catalog-entity";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import type { CatalogEntityRegistry } from "../../api/catalog/entity/registry";
import { EntitySettingRegistry } from "../../../extensions/registries";
import { groupBy } from "lodash";
import { SettingLayout } from "../layout/setting-layout";
@@ -20,20 +19,25 @@ import logger from "../../../common/logger";
import { Avatar } from "../avatar";
import { withInjectables } from "@ogre-tools/injectable-react";
import entitySettingsRouteParametersInjectable from "./entity-settings-route-parameters.injectable";
import type { ObservableHistory } from "mobx-observable-history";
import catalogEntityRegistryInjectable from "../../api/catalog/entity/registry.injectable";
import observableHistoryInjectable from "../../navigation/observable-history.injectable";
interface Dependencies {
entityId: IComputedValue<string>;
entityRegistry: CatalogEntityRegistry;
observableHistory: ObservableHistory<unknown>;
}
@observer
class NonInjectedEntitySettings extends React.Component<Dependencies> {
@observable activeTab: string;
@observable activeTab?: string;
constructor(props: Dependencies) {
super(props);
makeObservable(this);
const { hash } = navigation.location;
const { hash } = this.props.observableHistory.location;
if (hash) {
const menuId = hash.slice(1);
@@ -50,8 +54,8 @@ class NonInjectedEntitySettings extends React.Component<Dependencies> {
return this.props.entityId.get();
}
get entity(): CatalogEntity {
return catalogEntityRegistry.getById(this.entityId);
get entity() {
return this.props.entityRegistry.getById(this.entityId);
}
get menuItems() {
@@ -70,7 +74,7 @@ class NonInjectedEntitySettings extends React.Component<Dependencies> {
this.activeTab = tabId;
};
renderNavigation() {
renderNavigation(entity: CatalogEntity) {
const groups = Object.entries(groupBy(this.menuItems, (item) => item.group || "Extensions"));
groups.sort((a, b) => {
@@ -84,17 +88,22 @@ class NonInjectedEntitySettings extends React.Component<Dependencies> {
<>
<div className="flex items-center pb-8">
<Avatar
title={this.entity.getName()}
colorHash={`${this.entity.getName()}-${this.entity.metadata.source}`}
src={this.entity.spec.icon?.src}
title={entity.getName()}
colorHash={`${entity.getName()}-${entity.metadata.source}`}
src={entity.spec.icon?.src}
className={styles.settingsAvatar}
size={40}
/>
<div className={styles.entityName}>
{this.entity.getName()}
{entity.getName()}
</div>
</div>
<Tabs className="flex column" scrollable={false} onChange={this.onTabChange} value={this.activeTab}>
<Tabs
className="flex column"
scrollable={false}
onChange={this.onTabChange}
value={this.activeTab}
>
{ groups.map((group, groupIndex) => (
<React.Fragment key={`group-${groupIndex}`}>
<hr/>
@@ -115,17 +124,17 @@ class NonInjectedEntitySettings extends React.Component<Dependencies> {
}
render() {
if (!this.entity) {
const { activeSetting, entity } = this;
if (!entity) {
logger.error("[ENTITY-SETTINGS]: entity not found", this.entityId);
return null;
}
const { activeSetting } = this;
return (
<SettingLayout
navigation={this.renderNavigation()}
navigation={this.renderNavigation(entity)}
contentGaps={false}
>
{
@@ -133,7 +142,7 @@ class NonInjectedEntitySettings extends React.Component<Dependencies> {
<section>
<h2 data-testid={`${activeSetting.id}-header`}>{activeSetting.title}</h2>
<section>
<activeSetting.components.View entity={this.entity} key={activeSetting.title} />
<activeSetting.components.View entity={entity} key={activeSetting.title} />
</section>
</section>
)
@@ -143,16 +152,10 @@ class NonInjectedEntitySettings extends React.Component<Dependencies> {
}
}
export const EntitySettings = withInjectables<Dependencies>(
NonInjectedEntitySettings,
{
getProps: (di) => {
const routeParameters = di.inject(entitySettingsRouteParametersInjectable);
return {
entityId: routeParameters.entityId,
};
},
},
);
export const EntitySettings = withInjectables<Dependencies>(NonInjectedEntitySettings, {
getProps: (di) => ({
...di.inject(entitySettingsRouteParametersInjectable),
entityRegistry: di.inject(catalogEntityRegistryInjectable),
observableHistory: di.inject(observableHistoryInjectable),
}),
});
@@ -0,0 +1,27 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import React from "react";
import { ReactiveDuration } from "../duration/reactive-duration";
import { LocaleDate } from "../locale-date";
export interface DurationAbsoluteTimestampProps {
timestamp: string | undefined;
}
export const DurationAbsoluteTimestamp = ({ timestamp }: DurationAbsoluteTimestampProps) => {
if (!timestamp) {
return <>{"<unknown>"}</>;
}
return (
<>
<ReactiveDuration timestamp={timestamp} />
{" ago "}
(
<LocaleDate date={timestamp} />
)
</>
);
};
@@ -14,83 +14,95 @@ import type { KubeObjectDetailsProps } from "../kube-object-details";
import { KubeEvent } from "../../../common/k8s-api/endpoints/events.api";
import { KubeObjectMeta } from "../kube-object-meta";
import { Table, TableCell, TableHead, TableRow } from "../table";
import { LocaleDate } from "../locale-date";
import { getDetailsUrl } from "../kube-detail-params";
import { apiManager } from "../../../common/k8s-api/api-manager";
import type { ApiManager } from "../../../common/k8s-api/api-manager";
import logger from "../../../common/logger";
import { ReactiveDuration } from "../duration/reactive-duration";
import { DurationAbsoluteTimestamp } from "./duration-absolute";
import { withInjectables } from "@ogre-tools/injectable-react";
import type { GetDetailsUrl } from "../kube-detail-params/get-details-url.injectable";
import apiManagerInjectable from "../../../common/k8s-api/api-manager/manager.injectable";
import getDetailsUrlInjectable from "../kube-detail-params/get-details-url.injectable";
import { cssNames } from "../../utils";
export interface EventDetailsProps extends KubeObjectDetailsProps<KubeEvent> {
}
@observer
export class EventDetails extends React.Component<EventDetailsProps> {
render() {
const { object: event } = this.props;
if (!event) {
return null;
}
if (!(event instanceof KubeEvent)) {
logger.error("[EventDetails]: passed object that is not an instanceof KubeEvent", event);
return null;
}
const { message, reason, count, type, involvedObject } = event;
const { kind, name, namespace, fieldPath } = involvedObject;
return (
<div className="EventDetails">
<KubeObjectMeta object={event}/>
<DrawerItem name="Message">
{message}
</DrawerItem>
<DrawerItem name="Reason">
{reason}
</DrawerItem>
<DrawerItem name="Source">
{event.getSource()}
</DrawerItem>
<DrawerItem name="First seen">
<ReactiveDuration timestamp={event.firstTimestamp} />
{" ago "}
(<LocaleDate date={event.firstTimestamp} />)
</DrawerItem>
<DrawerItem name="Last seen">
<ReactiveDuration timestamp={event.lastTimestamp} />
{" ago "}
(<LocaleDate date={event.lastTimestamp} />)
</DrawerItem>
<DrawerItem name="Count">
{count}
</DrawerItem>
<DrawerItem name="Type" className="type">
<span className={kebabCase(type)}>{type}</span>
</DrawerItem>
<DrawerTitle>Involved object</DrawerTitle>
<Table>
<TableHead>
<TableCell>Name</TableCell>
<TableCell>Namespace</TableCell>
<TableCell>Kind</TableCell>
<TableCell>Field Path</TableCell>
</TableHead>
<TableRow>
<TableCell>
<Link to={getDetailsUrl(apiManager.lookupApiLink(involvedObject, event))}>
{name}
</Link>
</TableCell>
<TableCell>{namespace}</TableCell>
<TableCell>{kind}</TableCell>
<TableCell>{fieldPath}</TableCell>
</TableRow>
</Table>
</div>
);
}
interface Dependencies {
getDetailsUrl: GetDetailsUrl;
apiManager: ApiManager;
}
const NonInjectedEventDetails = observer(({
apiManager,
getDetailsUrl,
object: event,
className,
}: Dependencies & EventDetailsProps) => {
if (!event) {
return null;
}
if (!(event instanceof KubeEvent)) {
logger.error("[EventDetails]: passed object that is not an instanceof KubeEvent", event);
return null;
}
const { message, reason, count, type, involvedObject } = event;
const { kind, name, namespace, fieldPath } = involvedObject;
return (
<div className={cssNames("EventDetails", className)}>
<KubeObjectMeta object={event}/>
<DrawerItem name="Message">
{message}
</DrawerItem>
<DrawerItem name="Reason">
{reason}
</DrawerItem>
<DrawerItem name="Source">
{event.getSource()}
</DrawerItem>
<DrawerItem name="First seen">
<DurationAbsoluteTimestamp timestamp={event.firstTimestamp} />
</DrawerItem>
<DrawerItem name="Last seen">
<DurationAbsoluteTimestamp timestamp={event.lastTimestamp} />
</DrawerItem>
<DrawerItem name="Count">
{count}
</DrawerItem>
<DrawerItem name="Type" className="type">
<span className={kebabCase(type)}>{type}</span>
</DrawerItem>
<DrawerTitle>Involved object</DrawerTitle>
<Table>
<TableHead>
<TableCell>Name</TableCell>
<TableCell>Namespace</TableCell>
<TableCell>Kind</TableCell>
<TableCell>Field Path</TableCell>
</TableHead>
<TableRow>
<TableCell>
<Link to={getDetailsUrl(apiManager.lookupApiLink(involvedObject, event))}>
{name}
</Link>
</TableCell>
<TableCell>{namespace}</TableCell>
<TableCell>{kind}</TableCell>
<TableCell>{fieldPath}</TableCell>
</TableRow>
</Table>
</div>
);
});
export const EventDetails = withInjectables<Dependencies, EventDetailsProps>(NonInjectedEventDetails, {
getProps: (di, props) => ({
...props,
apiManager: di.inject(apiManagerInjectable),
getDetailsUrl: di.inject(getDetailsUrlInjectable),
}),
});
+53 -43
View File
@@ -5,16 +5,15 @@
import "./events.scss";
import React, { Fragment } from "react";
import React from "react";
import { computed, observable, makeObservable } from "mobx";
import { observer } from "mobx-react";
import { orderBy } from "lodash";
import { TabLayout } from "../layout/tab-layout-2";
import type { EventStore } from "./event.store";
import { eventStore } from "./event.store";
import type { EventStore } from "./store";
import type { KubeObjectListLayoutProps } from "../kube-object-list-layout";
import { KubeObjectListLayout } from "../kube-object-list-layout";
import type { KubeEvent } from "../../../common/k8s-api/endpoints/events.api";
import type { KubeEvent, KubeEventApi, KubeEventData } from "../../../common/k8s-api/endpoints/events.api";
import type { TableSortCallbacks, TableSortParams } from "../table";
import type { HeaderCustomizer } from "../item-object-list";
import { Tooltip } from "../tooltip";
@@ -23,11 +22,13 @@ import type { IClassName } from "../../utils";
import { cssNames, stopPropagation } from "../../utils";
import { Icon } from "../icon";
import { getDetailsUrl } from "../kube-detail-params";
import { apiManager } from "../../../common/k8s-api/api-manager";
import type { ApiManager } from "../../../common/k8s-api/api-manager";
import { withInjectables } from "@ogre-tools/injectable-react";
import navigateToEventsInjectable from "../../../common/front-end-routing/routes/cluster/events/navigate-to-events.injectable";
import { KubeObjectAge } from "../kube-object/age";
import { ReactiveDuration } from "../duration/reactive-duration";
import apiManagerInjectable from "../../../common/k8s-api/api-manager/manager.injectable";
import eventStoreInjectable from "./store.injectable";
enum columnId {
message = "message",
@@ -40,7 +41,7 @@ enum columnId {
lastSeen = "last-seen",
}
export interface EventsProps extends Partial<KubeObjectListLayoutProps<KubeEvent>> {
export interface EventsProps extends Partial<KubeObjectListLayoutProps<KubeEvent, KubeEventApi, KubeEventData>> {
className?: IClassName;
compact?: boolean;
compactLimit?: number;
@@ -52,6 +53,8 @@ const defaultProps: Partial<EventsProps> = {
interface Dependencies {
navigateToEvents: () => void;
eventStore: EventStore;
apiManager: ApiManager;
}
@observer
@@ -69,7 +72,7 @@ class NonInjectedEvents extends React.Component<Dependencies & EventsProps> {
[columnId.object]: event => event.involvedObject.name,
[columnId.count]: event => event.count,
[columnId.age]: event => -event.getCreationTimestamp(),
[columnId.lastSeen]: event => -new Date(event.lastTimestamp).getTime(),
[columnId.lastSeen]: event => event.lastTimestamp ? -new Date(event.lastTimestamp).getTime() : 0,
};
constructor(props: Dependencies & EventsProps) {
@@ -77,17 +80,13 @@ class NonInjectedEvents extends React.Component<Dependencies & EventsProps> {
makeObservable(this);
}
get store(): EventStore {
return eventStore;
}
@computed get items(): KubeEvent[] {
const items = this.store.contextItems;
const items = this.props.eventStore.contextItems;
const { sortBy, orderBy: order } = this.sorting;
// we must sort items before passing to "KubeObjectListLayout -> Table"
// to make it work with "compact=true" (proper table sorting actions + initial items)
return orderBy(items, this.sortingCallbacks[sortBy], order as any);
return orderBy(items, this.sortingCallbacks[sortBy], order);
}
@computed get visibleItems(): KubeEvent[] {
@@ -101,8 +100,8 @@ class NonInjectedEvents extends React.Component<Dependencies & EventsProps> {
}
customizeHeader: HeaderCustomizer = ({ info, title, ...headerPlaceholders }) => {
const { compact } = this.props;
const { store, items, visibleItems } = this;
const { compact, eventStore } = this.props;
const { items, visibleItems } = this;
const allEventsAreShown = visibleItems.length === items.length;
// handle "compact"-mode header
@@ -113,35 +112,44 @@ class NonInjectedEvents extends React.Component<Dependencies & EventsProps> {
return {
title,
info: <span> ({visibleItems.length} of <a onClick={this.props.navigateToEvents}>{items.length}</a>)</span>,
info: (
<span>
{"("}
{visibleItems.length}
{" of "}
<a onClick={this.props.navigateToEvents}>{items.length}</a>
{")"}
</span>
),
};
}
return {
info: <>
{info}
<Icon
small
material="help_outline"
className="help-icon"
tooltip={`Limited to ${store.limit}`}
/>
</>,
info: (
<>
{info}
<Icon
small
material="help_outline"
className="help-icon"
tooltip={`Limited to ${eventStore.limit}`}
/>
</>
),
title,
...headerPlaceholders,
};
};
render() {
const { store } = this;
const { compact, compactLimit, className, ...layoutProps } = this.props;
const { apiManager, eventStore, compact, compactLimit, className, ...layoutProps } = this.props;
const events = (
<KubeObjectListLayout
{...layoutProps}
isConfigurable
tableId="events"
store={store}
store={eventStore}
className={cssNames("Events", className, { compact })}
renderHeaderTitle="Events"
customizeHeader={this.customizeHeader}
@@ -178,19 +186,23 @@ class NonInjectedEvents extends React.Component<Dependencies & EventsProps> {
return [
type, // type of event: "Normal" or "Warning"
{
className: { warning: isWarning },
className: cssNames({ warning: isWarning }),
title: (
<Fragment>
<>
<span id={tooltipId}>{message}</span>
<Tooltip targetId={tooltipId} formatters={{ narrow: true, warning: isWarning }}>
{message}
</Tooltip>
</Fragment>
</>
),
},
event.getNs(),
<Link key="link" to={getDetailsUrl(apiManager.lookupApiLink(involvedObject, event))} onClick={stopPropagation}>
{involvedObject.kind}: {involvedObject.name}
<Link
key="link"
to={getDetailsUrl(apiManager.lookupApiLink(involvedObject, event))}
onClick={stopPropagation}
>
{`${involvedObject.kind}: ${involvedObject.name}`}
</Link>,
event.getSource(),
event.count,
@@ -213,13 +225,11 @@ class NonInjectedEvents extends React.Component<Dependencies & EventsProps> {
}
}
export const Events = withInjectables<Dependencies, EventsProps>(
NonInjectedEvents,
{
getProps: (di, props) => ({
navigateToEvents: di.inject(navigateToEventsInjectable),
...props,
}),
},
);
export const Events = withInjectables<Dependencies, EventsProps>(NonInjectedEvents, {
getProps: (di, props) => ({
...props,
navigateToEvents: di.inject(navigateToEventsInjectable),
apiManager: di.inject(apiManagerInjectable),
eventStore: di.inject(eventStoreInjectable),
}),
});
@@ -9,22 +9,23 @@ import React from "react";
import { disposeOnUnmount, observer } from "mobx-react";
import { KubeObject } from "../../../common/k8s-api/kube-object";
import { DrawerItem, DrawerTitle } from "../drawer";
import type { Disposer } from "../../utils";
import { cssNames } from "../../utils";
import { LocaleDate } from "../locale-date";
import { eventStore } from "./event.store";
import type { EventStore } from "./store";
import logger from "../../../common/logger";
import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import { withInjectables } from "@ogre-tools/injectable-react";
import kubeWatchApiInjectable
from "../../kube-watch-api/kube-watch-api.injectable";
import type { SubscribeStores } from "../../kube-watch-api/kube-watch-api";
import subscribeStoresInjectable from "../../kube-watch-api/subscribe-stores.injectable";
import eventStoreInjectable from "./store.injectable";
export interface KubeEventDetailsProps {
object: KubeObject;
}
interface Dependencies {
subscribeStores: (stores: KubeObjectStore<KubeObject>[]) => Disposer;
subscribeStores: SubscribeStores;
eventStore: EventStore;
}
@observer
@@ -32,13 +33,13 @@ class NonInjectedKubeEventDetails extends React.Component<KubeEventDetailsProps
componentDidMount() {
disposeOnUnmount(this, [
this.props.subscribeStores([
eventStore,
this.props.eventStore,
]),
]);
}
render() {
const { object } = this.props;
const { object, eventStore } = this.props;
if (!object) {
return null;
@@ -73,9 +74,11 @@ class NonInjectedKubeEventDetails extends React.Component<KubeEventDetailsProps
<DrawerItem name="Sub-object">
{event.involvedObject.fieldPath}
</DrawerItem>
<DrawerItem name="Last seen">
<LocaleDate date={event.lastTimestamp} />
</DrawerItem>
{event.lastTimestamp && (
<DrawerItem name="Last seen">
<LocaleDate date={event.lastTimestamp} />
</DrawerItem>
)}
</div>
))}
</div>
@@ -85,16 +88,13 @@ class NonInjectedKubeEventDetails extends React.Component<KubeEventDetailsProps
}
}
export const KubeEventDetails = withInjectables<Dependencies, KubeEventDetailsProps>(
NonInjectedKubeEventDetails,
{
getProps: (di, props) => ({
subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores,
...props,
}),
},
);
export const KubeEventDetails = withInjectables<Dependencies, KubeEventDetailsProps>(NonInjectedKubeEventDetails, {
getProps: (di, props) => ({
...props,
subscribeStores: di.inject(subscribeStoresInjectable),
eventStore: di.inject(eventStoreInjectable),
}),
});
@@ -8,7 +8,7 @@ import "./kube-event-icon.scss";
import React from "react";
import { Icon } from "../icon";
import type { KubeObject } from "../../../common/k8s-api/kube-object";
import { eventStore } from "./event.store";
import { eventStore } from "./legacy-store";
import { cssNames } from "../../utils";
import type { KubeEvent } from "../../../common/k8s-api/endpoints/events.api";
import { KubeObjectAge } from "../kube-object/age";
@@ -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 eventStoreInjectable from "./store.injectable";
/**
* @deprecated use `di.inject(eventStoreInjectable)` instead
*/
export const eventStore = asLegacyGlobalForExtensionApi(eventStoreInjectable);
@@ -0,0 +1,27 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import assert from "assert";
import getPodByIdInjectable from "../+workloads-pods/get-pod-by-id.injectable";
import { kubeObjectStoreInjectionToken } from "../../../common/k8s-api/api-manager/manager.injectable";
import kubeEventApiInjectable from "../../../common/k8s-api/endpoints/events.api.injectable";
import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable";
import { EventStore } from "./store";
const eventStoreInjectable = getInjectable({
id: "event-store",
instantiate: (di) => {
assert(di.inject(storesAndApisCanBeCreatedInjectable), "eventStore is only available in certain environments");
const api = di.inject(kubeEventApiInjectable);
return new EventStore({
getPodById: di.inject(getPodByIdInjectable),
}, api);
},
injectionToken: kubeObjectStoreInjectionToken,
});
export default eventStoreInjectable;
@@ -5,22 +5,25 @@
import groupBy from "lodash/groupBy";
import compact from "lodash/compact";
import type { KubeObjectStoreOptions } from "../../../common/k8s-api/kube-object.store";
import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import { autoBind } from "../../utils";
import type { KubeEvent } from "../../../common/k8s-api/endpoints/events.api";
import { eventApi } from "../../../common/k8s-api/endpoints/events.api";
import type { KubeEvent, KubeEventApi } from "../../../common/k8s-api/endpoints/events.api";
import type { KubeObject } from "../../../common/k8s-api/kube-object";
import { Pod } from "../../../common/k8s-api/endpoints/pods.api";
import { podsStore } from "../+workloads-pods/pods.store";
import { apiManager } from "../../../common/k8s-api/api-manager";
import { Pod } from "../../../common/k8s-api/endpoints/pod.api";
import type { GetPodById } from "../+workloads-pods/get-pod-by-id.injectable";
export class EventStore extends KubeObjectStore<KubeEvent> {
api = eventApi;
limit = 1000;
saveLimit = 50000;
export interface EventStoreDependencies {
getPodById: GetPodById;
}
constructor() {
super();
export class EventStore extends KubeObjectStore<KubeEvent, KubeEventApi> {
constructor(
protected readonly dependencies: EventStoreDependencies,
api: KubeEventApi,
opts: KubeObjectStoreOptions = {},
) {
super(api, { limit: 1000, ...opts });
autoBind(this);
}
@@ -52,9 +55,11 @@ export class EventStore extends KubeObjectStore<KubeEvent> {
const { kind, uid } = recent.involvedObject;
if (kind == Pod.kind) { // Wipe out running pods
const pod = podsStore.items.find(pod => pod.getId() == uid);
const pod = this.dependencies.getPodById(uid);
if (!pod || (!pod.hasIssues() && pod.spec.priority < 500000)) return undefined;
if (!pod || (!pod.hasIssues() && (pod.spec?.priority ?? 0) < 500000)) {
return undefined;
}
}
return recent;
@@ -67,6 +72,3 @@ export class EventStore extends KubeObjectStore<KubeEvent> {
return this.getWarnings().length;
}
}
export const eventStore = new EventStore();
apiManager.registerStore(eventStore);
@@ -4,7 +4,7 @@
*/
import "@testing-library/jest-dom/extend-expect";
import { fireEvent, waitFor } from "@testing-library/react";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import fse from "fs-extra";
import React from "react";
import { UserStore } from "../../../../common/user-store";
@@ -21,10 +21,14 @@ import { renderFor } from "../../test-utils/renderFor";
import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable";
import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import directoryForDownloadsInjectable from "../../../../common/app-paths/directory-for-downloads/directory-for-downloads.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 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 assert from "assert";
import type { InstallFromInput } from "../install-from-input/install-from-input";
import installFromInputInjectable from "../install-from-input/install-from-input.injectable";
import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store";
import extensionInstallationStateStoreInjectable from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import { observable, when } from "mobx";
mockWindow();
@@ -50,6 +54,8 @@ jest.mock("../../../../common/utils/tar");
describe("Extensions", () => {
let extensionLoader: ExtensionLoader;
let extensionDiscovery: ExtensionDiscovery;
let installFromInput: jest.MockedFunction<InstallFromInput>;
let extensionInstallationStateStore: ExtensionInstallationStateStore;
let render: DiRender;
beforeEach(async () => {
@@ -69,8 +75,13 @@ describe("Extensions", () => {
render = renderFor(di);
installFromInput = jest.fn();
di.override(installFromInputInjectable, () => installFromInput);
extensionLoader = di.inject(extensionLoaderInjectable);
extensionDiscovery = di.inject(extensionDiscoveryInjectable);
extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
extensionLoader.addExtension({
id: "extensionId",
@@ -99,36 +110,53 @@ describe("Extensions", () => {
it("disables uninstall and disable buttons while uninstalling", async () => {
extensionDiscovery.isLoaded = true;
const res = render(<><Extensions /><ConfirmDialog /></>);
const table = res.getByTestId("extensions-table");
const menuTrigger = table.querySelector("div[role=row]:first-of-type .actions .Icon");
render((
<>
<Extensions />
<ConfirmDialog />
</>
));
const table = await screen.findByTestId("extensions-table");
const menuTrigger = table.querySelector(".table div[role='rowgroup'] .actions .Icon");
assert(menuTrigger);
fireEvent.click(menuTrigger);
expect(res.getByText("Disable")).toHaveAttribute("aria-disabled", "false");
expect(res.getByText("Uninstall")).toHaveAttribute("aria-disabled", "false");
expect(await screen.findByText("Disable")).toHaveAttribute("aria-disabled", "false");
expect(await screen.findByText("Uninstall")).toHaveAttribute("aria-disabled", "false");
fireEvent.click(res.getByText("Uninstall"));
fireEvent.click(await screen.findByText("Uninstall"));
// Approve confirm dialog
fireEvent.click(res.getByText("Yes"));
fireEvent.click(await screen.findByText("Yes"));
await waitFor(() => {
expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled();
fireEvent.click(menuTrigger);
expect(res.getByText("Disable")).toHaveAttribute("aria-disabled", "true");
expect(res.getByText("Uninstall")).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("Disable")).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("Uninstall")).toHaveAttribute("aria-disabled", "true");
}, {
timeout: 30000,
});
});
it("disables install button while installing", async () => {
const res = render(<Extensions />);
render(<Extensions />);
(fse.unlink as jest.MockedFunction<typeof fse.unlink>).mockReturnValue(Promise.resolve() as any);
const resolveInstall = observable.box(false);
fireEvent.change(res.getByPlaceholderText("File path or URL", {
(fse.unlink as jest.MockedFunction<typeof fse.unlink>).mockReturnValue(Promise.resolve());
installFromInput.mockImplementation(async (input) => {
expect(input).toBe("https://test.extensionurl/package.tgz");
const clear = extensionInstallationStateStore.startPreInstall();
await when(() => resolveInstall.get());
clear();
});
fireEvent.change(await screen.findByPlaceholderText("File path or URL", {
exact: false,
}), {
target: {
@@ -136,8 +164,9 @@ describe("Extensions", () => {
},
});
fireEvent.click(res.getByText("Install"));
expect(res.getByText("Install").closest("button")).toBeDisabled();
fireEvent.click(await screen.findByText("Install"));
expect((await screen.findByText("Install")).closest("button")).toBeDisabled();
resolveInstall.set(true);
});
it("displays spinner while extensions are loading", () => {
@@ -10,7 +10,6 @@ import path from "path";
import { SemVer } from "semver";
import URLParse from "url-parse";
import type { InstallRequest } from "./attempt-install/install-request";
import { reduce } from "lodash";
import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store";
import type { Confirm } from "../confirm-dialog/confirm.injectable";
import { getInjectable } from "@ogre-tools/injectable";
@@ -18,6 +17,7 @@ import attemptInstallInjectable from "./attempt-install/attempt-install.injectab
import getBaseRegistryUrlInjectable from "./get-base-registry-url/get-base-registry-url.injectable";
import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import confirmInjectable from "../confirm-dialog/confirm.injectable";
import { reduce } from "lodash";
export interface ExtensionInfo {
name: string;
@@ -78,7 +78,11 @@ const attemptInstallByInfo = ({
} else {
Notifications.error((
<p>
The <em>{name}</em> extension does not have a version or tag <code>{version}</code>.
{"The "}
<em>{name}</em>
{" extension does not have a version or tag "}
<code>{version}</code>
.
</p>
));
@@ -91,19 +95,28 @@ const attemptInstallByInfo = ({
// ignore pre-releases for auto picking the version
.filter(version => version.prerelease.length === 0);
finalVersion = reduce(
versions,
(prev, curr) => prev.compareMain(curr) === -1 ? curr : prev,
).format();
const latestVersion = reduce(versions, (prev, curr) => prev.compareMain(curr) === -1 ? curr : prev);
if (!latestVersion) {
console.error("No versions supplied for that extension", { name });
Notifications.error(`No versions found for ${name}`);
return disposer();
}
finalVersion = latestVersion.format();
}
if (requireConfirmation) {
const proceed = await confirm({
message: (
<p>
Are you sure you want to install{" "}
Are you sure you want to install
{" "}
<b>
{name}@{finalVersion}
{name}
@
{finalVersion}
</b>
?
</p>
@@ -117,7 +130,8 @@ const attemptInstallByInfo = ({
}
}
const url = json.versions[finalVersion].dist.tarball;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const url = json.versions[finalVersion!].dist.tarball;
const fileName = path.basename(url);
const { promise: dataP } = downloadFile({ url, timeout: 10 * 60 * 1000 });
@@ -74,7 +74,9 @@ export const attemptInstall =
<div className="flex column gaps">
<b>Extension Install Collision:</b>
<p>
The <em>{name}</em> extension is currently {curState.toLowerCase()}.
{"The "}
<em>{name}</em>
{` extension is currently ${curState.toLowerCase()}.`}
</p>
<p>Will not proceed with this current install request.</p>
</div>,
@@ -92,21 +94,20 @@ export const attemptInstall =
<div className="InstallingExtensionNotification flex gaps align-center">
<div className="flex column gaps">
<p>
Install extension{" "}
<b>
{name}@{version}
</b>
?
{"Install extension "}
<b>{`${name}@${version}`}</b>
?
</p>
<p>
Description: <em>{description}</em>
{"Description: "}
<em>{description}</em>
</p>
<div
className="remove-folder-warning"
onClick={() => shell.openPath(extensionFolder)}
>
<b>Warning:</b> {name}@{oldVersion} will be removed before
installation.
<b>Warning:</b>
{` ${name}@${oldVersion} will be removed before installation.`}
</div>
</div>
<Button
@@ -73,10 +73,13 @@ export const createTempFilesAndValidate =
Notifications.error(
<div className="flex column gaps">
<p>
Installing <em>{fileName}</em> has failed, skipping.
{"Installing "}
<em>{fileName}</em>
{" has failed, skipping."}
</p>
<p>
Reason: <em>{message}</em>
{"Reason: "}
<em>{message}</em>
</p>
</div>,
);
@@ -75,7 +75,9 @@ export const unpackExtension =
Notifications.ok(
<p>
Extension <b>{displayName}</b> successfully installed!
{"Extension "}
<b>{displayName}</b>
{" successfully installed!"}
</p>,
);
} catch (error) {
@@ -87,7 +89,9 @@ export const unpackExtension =
);
Notifications.error(
<p>
Installing extension <b>{displayName}</b> has failed:{" "}
{"Installing extension "}
<b>{displayName}</b>
{" has failed: "}
<em>{message}</em>
</p>,
);
@@ -3,7 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { LensExtensionManifest } from "../../../../../extensions/lens-extension";
import { listTarEntries, readFileFromTar } from "../../../../../common/utils";
import { hasTypedProperty, isObject, isString, listTarEntries, readFileFromTar } from "../../../../../common/utils";
import { manifestFilename } from "../../../../../extensions/extension-discovery/extension-discovery";
import path from "path";
@@ -31,17 +31,21 @@ export const validatePackage = async (
throw new Error(`invalid extension bundle, ${manifestFilename} not found`);
}
const manifest = await readFileFromTar<LensExtensionManifest>({
const manifest = await readFileFromTar({
tarPath: filePath,
filePath: manifestLocation,
parseJson: true,
});
if (!manifest.main && !manifest.renderer) {
throw new Error(
`${manifestFilename} must specify "main" and/or "renderer" fields`,
);
if (
isObject(manifest)
&& (
hasTypedProperty(manifest, "main", isString)
|| hasTypedProperty(manifest, "renderer", isString)
)
) {
return manifest as unknown as LensExtensionManifest;
}
return manifest;
throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`);
};
@@ -30,7 +30,9 @@ const confirmUninstallExtension = ({
const confirmed = await confirm({
message: (
<p>
Are you sure you want to uninstall extension <b>{displayName}</b>?
{"Are you sure you want to uninstall extension "}
<b>{displayName}</b>
?
</p>
),
labelOk: "Yes",
@@ -35,13 +35,14 @@ import installOnDropInjectable from "./install-on-drop/install-on-drop.injectabl
import { supportedExtensionFormats } from "./supported-extension-formats";
import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store";
import type { InstallFromInput } from "./install-from-input/install-from-input";
interface Dependencies {
userExtensions: IComputedValue<InstalledExtension[]>;
enableExtension: (id: LensExtensionId) => void;
disableExtension: (id: LensExtensionId) => void;
confirmUninstallExtension: ConfirmUninstallExtension;
installFromInput: (input: string) => Promise<void>;
installFromInput: InstallFromInput;
installFromSelectFileDialog: () => Promise<void>;
installOnDrop: (files: File[]) => Promise<void>;
extensionInstallationStateStore: ExtensionInstallationStateStore;
@@ -73,15 +74,33 @@ class NonInjectedExtensions extends React.Component<Dependencies> {
return (
<DropFileInput onDropFiles={this.props.installOnDrop}>
<SettingLayout className="Extensions" contentGaps={false} data-testid="extensions-page">
<SettingLayout
className="Extensions"
contentGaps={false}
data-testid="extensions-page"
>
<section>
<h1>Extensions</h1>
<Notice className="mb-14 mt-3">
<p>
Add new features via Lens Extensions.{" "}
Check out <a href={`${docsUrl}/extensions/`} target="_blank" rel="noreferrer">docs</a>{" "}
and list of <a href="https://github.com/lensapp/lens-extensions/blob/main/README.md" target="_blank" rel="noreferrer">available extensions</a>.
{"Add new features via Lens Extensions. Check out the "}
<a
href={`${docsUrl}/extensions/`}
target="_blank"
rel="noreferrer"
>
docs
</a>
{" and list of "}
<a
href="https://github.com/lensapp/lens-extensions/blob/main/README.md"
target="_blank"
rel="noreferrer"
>
available extensions
</a>
.
</p>
</Notice>
@@ -5,7 +5,7 @@
import React from "react";
import type { ExtensionRegistry } from "../../../../common/user-store/preferences-helpers";
import { defaultExtensionRegistryUrl, ExtensionRegistryLocation } from "../../../../common/user-store/preferences-helpers";
import { defaultExtensionRegistryUrl } from "../../../../common/user-store/preferences-helpers";
import { promiseExecFile } from "../../../utils";
import { Notifications } from "../../notifications";
@@ -17,10 +17,10 @@ export const getBaseRegistryUrl = ({ getRegistryUrlPreference }: Dependencies) =
const extensionRegistryUrl = getRegistryUrlPreference();
switch (extensionRegistryUrl.location) {
case ExtensionRegistryLocation.CUSTOM:
case "custom":
return extensionRegistryUrl.customUrl;
case ExtensionRegistryLocation.NPMRC: {
case "npmrc": {
try {
const filteredEnv = Object.fromEntries(
Object.entries(process.env)
@@ -30,13 +30,20 @@ export const getBaseRegistryUrl = ({ getRegistryUrlPreference }: Dependencies) =
return stdout.trim();
} catch (error) {
Notifications.error(<p>Failed to get configured registry from <code>.npmrc</code>. Falling back to default registry</p>);
Notifications.error((
<p>
Failed to get configured registry from
<code>.npmrc</code>
. Falling back to default registry.
</p>
));
console.warn("[EXTENSIONS]: failed to get configured registry from .npmrc", error);
}
// fallthrough
}
// fallthrough
default:
case ExtensionRegistryLocation.DEFAULT:
case "default":
return defaultExtensionRegistryUrl;
}
};
@@ -2,16 +2,19 @@
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export function getMessageFromError(error: any): string {
import { hasTypedProperty, isDefined } from "../../../utils";
export function getMessageFromError(error: unknown): string {
if (!error || typeof error !== "object") {
return "an error has occurred";
}
if (error.message) {
return String(error.message);
if (error instanceof Error) {
return error.message;
}
if (error.err) {
if (hasTypedProperty(error, "err", isDefined)) {
return String(error.err);
}
Loaded 100 of 1062 files, more files were not shown because too many files have changed in this diff. Show more