Restructure to monorepo (#6907)

* wip: restructure to monorepo

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

* refactor create-release-pr to a package

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

* build fixes

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

* github workflow fixes

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

* fix typo

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

* add webpack-env types to core

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

* fix github workflows

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

* refactor/fix integration tests

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

* lint fix

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

* yarn run dev

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

* eslint settings for vscode

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

* move templates to right package

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

* open-lens build fixes

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

* integration test fix

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

* fix nx task dependencies

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

* use bash shell for unit tests in test workflow

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

* fix test:unit for windows

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

* fix win-ca webpack error in open-lens

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

* fix win-ca webpack error in open-lens

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

* fix build:app on windows

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

* remove ELECTRON_BUILDER_EXTRA_ARGS

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

* sync src/ from master

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

* remove Makefile from core

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

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

No files matched your search

@@ -0,0 +1,21 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { AddCluster } from "./add-cluster";
import { routeSpecificComponentInjectionToken } from "../../routes/route-specific-component-injection-token";
import addClusterRouteInjectable from "../../../common/front-end-routing/routes/add-cluster/add-cluster-route.injectable";
const addClusterRouteComponentInjectable = getInjectable({
id: "add-cluster-route-component",
instantiate: (di) => ({
route: di.inject(addClusterRouteInjectable),
Component: AddCluster,
}),
injectionToken: routeSpecificComponentInjectionToken,
});
export default addClusterRouteComponentInjectable;
@@ -0,0 +1,31 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
@import "../../components/mixins.scss";
.AddClusters {
--flex-gap: calc(var(--unit) * 2);
code {
color: rgb(236, 64, 122);
}
a[href] {
color: var(--colorInfo);
}
}
.editor {
min-height: 600px;
max-height: 600px;
border: 1px solid var(--colorVague);
border-radius: var(--border-radius);
}
@include theme-light {
.editor {
border-color: var(--borderFaintColor);
}
}
@@ -0,0 +1,173 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./add-cluster.module.scss";
import type { KubeConfig } from "@kubernetes/client-node";
import fse from "fs-extra";
import { debounce } from "lodash";
import { action, computed, makeObservable, observable } from "mobx";
import { observer } from "mobx-react";
import React from "react";
import * as uuid from "uuid";
import { loadConfigFromString, splitConfig } from "../../../common/kube-helpers";
import { docsUrl } from "../../../common/vars";
import { isDefined, iter } from "../../utils";
import { Button } from "../button";
import type { ShowNotification } from "../notifications";
import { SettingLayout } from "../layout/setting-layout";
import { MonacoEditor } from "../monaco-editor";
import { withInjectables } from "@ogre-tools/injectable-react";
import getCustomKubeConfigFilePathInjectable from "../../../common/app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable";
import type { NavigateToCatalog } from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";
import navigateToCatalogInjectable from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";
import type { EmitAppEvent } from "../../../common/app-event-bus/emit-event.injectable";
import emitAppEventInjectable from "../../../common/app-event-bus/emit-event.injectable";
import type { GetDirnameOfPath } from "../../../common/path/get-dirname.injectable";
import getDirnameOfPathInjectable from "../../../common/path/get-dirname.injectable";
import showSuccessNotificationInjectable from "../notifications/show-success-notification.injectable";
import showErrorNotificationInjectable from "../notifications/show-error-notification.injectable";
interface Option {
config: KubeConfig;
error?: string;
}
interface Dependencies {
getCustomKubeConfigDirectory: (directoryName: string) => string;
navigateToCatalog: NavigateToCatalog;
getDirnameOfPath: GetDirnameOfPath;
emitAppEvent: EmitAppEvent;
showSuccessNotification: ShowNotification;
showErrorNotification: ShowNotification;
}
function getContexts(config: KubeConfig): Map<string, Option> {
return new Map(
splitConfig(config)
.map(({ config, validationResult }) => [config.currentContext, {
config,
error: validationResult.error?.toString(),
}]),
);
}
@observer
class NonInjectedAddCluster extends React.Component<Dependencies> {
@observable kubeContexts = observable.map<string, Option>();
@observable customConfig = "";
@observable isWaiting = false;
@observable errors: string[] = [];
constructor(props: Dependencies) {
super(props);
makeObservable(this);
}
componentDidMount() {
this.props.emitAppEvent({ name: "cluster-add", action: "start" });
}
@computed get allErrors(): string[] {
return [
...this.errors,
...iter.map(this.kubeContexts.values(), ({ error }) => error),
].filter(isDefined);
}
readonly refreshContexts = debounce(action(() => {
const { config, error } = loadConfigFromString(this.customConfig.trim() || "{}");
this.kubeContexts.replace(getContexts(config));
if (error) {
this.errors.push(error.toString());
}
if (config.contexts.length === 0) {
this.errors.push('No contexts defined, either missing the "contexts" field, or it is empty.');
}
}), 500);
addClusters = action(async () => {
this.isWaiting = true;
this.props.emitAppEvent({ name: "cluster-add", action: "click" });
try {
const absPath = this.props.getCustomKubeConfigDirectory(uuid.v4());
await fse.ensureDir(this.props.getDirnameOfPath(absPath));
await fse.writeFile(absPath, this.customConfig.trim(), { encoding: "utf-8", mode: 0o600 });
this.props.showSuccessNotification(`Successfully added ${this.kubeContexts.size} new cluster(s)`);
return this.props.navigateToCatalog();
} catch (error) {
this.props.showErrorNotification(`Failed to add clusters: ${error}`);
}
});
render() {
return (
<SettingLayout className={styles.AddClusters} data-testid="add-cluster-page">
<h2>Add Clusters from Kubeconfig</h2>
<p>
{"Clusters added here are "}
<b>not</b>
{" merged into the "}
<code>~/.kube/config</code>
{" file. "}
<a
href={`${docsUrl}/getting-started/add-cluster/`}
rel="noreferrer"
target="_blank"
>
Read more about adding clusters.
</a>
</p>
<div className="flex column">
<MonacoEditor
autoFocus
className={styles.editor}
value={this.customConfig}
onChange={value => {
this.customConfig = value;
this.errors.length = 0;
this.refreshContexts();
}}
/>
</div>
{this.allErrors.length > 0 && (
<>
<h3>KubeConfig Yaml Validation Errors:</h3>
{this.allErrors.map(error => <div key={error} className="error">{error}</div>)}
</>
)}
<div className="actions-panel">
<Button
primary
disabled={this.kubeContexts.size === 0}
label={this.kubeContexts.size === 1 ? "Add cluster" : "Add clusters"}
onClick={this.addClusters}
waiting={this.isWaiting}
tooltip={this.kubeContexts.size === 0 || "Paste in at least one cluster to add."}
tooltipOverrideDisabled
/>
</div>
</SettingLayout>
);
}
}
export const AddCluster = withInjectables<Dependencies>(NonInjectedAddCluster, {
getProps: (di) => ({
getCustomKubeConfigDirectory: di.inject(getCustomKubeConfigFilePathInjectable),
navigateToCatalog: di.inject(navigateToCatalogInjectable),
getDirnameOfPath: di.inject(getDirnameOfPathInjectable),
emitAppEvent: di.inject(emitAppEventInjectable),
showSuccessNotification: di.inject(showSuccessNotificationInjectable),
showErrorNotification: di.inject(showErrorNotificationInjectable),
}),
});
@@ -0,0 +1,6 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export * from "./add-cluster";
@@ -0,0 +1,89 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import React from "react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { CatalogCategorySpec } from "../../../../common/catalog";
import { CatalogCategory } from "../../../../common/catalog";
import { CatalogAddButton } from "../catalog-add-button";
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import { type DiRender, renderFor } from "../../test-utils/renderFor";
class TestCatalogCategory extends CatalogCategory {
public readonly apiVersion = "catalog.k8slens.dev/v1alpha1";
public readonly kind = "CatalogCategory";
public metadata = {
name: "Test Category",
icon: "",
};
public spec: CatalogCategorySpec = {
group: "entity.k8slens.dev",
versions: [],
names: {
kind: "Test",
},
};
}
describe("CatalogAddButton", () => {
let render: DiRender;
beforeEach(() => {
const di = getDiForUnitTesting({ doGeneralOverrides: true });
render = renderFor(di);
});
it("opens Add menu", async () => {
const category = new TestCatalogCategory();
category.on("catalogAddMenu", ctx => {
ctx.menuItems.push(
{
icon: "text_snippet",
title: "Add from kubeconfig",
onClick: () => {},
},
);
});
render(<CatalogAddButton category={category}/>);
userEvent.hover(screen.getByLabelText("SpeedDial CatalogAddButton"));
await screen.findByTitle("Add from kubeconfig");
});
it("filters menu items", async () => {
const category = new TestCatalogCategory();
category.on("catalogAddMenu", ctx => {
ctx.menuItems.push(
{
icon: "text_snippet",
title: "foobar",
onClick: () => {},
},
);
ctx.menuItems.push(
{
icon: "text_snippet",
title: "Add from kubeconfig",
onClick: () => {},
},
);
});
category.addMenuFilter(item => item.title === "foobar");
render(<CatalogAddButton category={category}/>);
userEvent.hover(screen.getByLabelText("SpeedDial CatalogAddButton"));
await expect(screen.findByTitle("Add from kubeconfig"))
.rejects
.toThrow();
await screen.findByTitle("foobar");
});
});
@@ -0,0 +1,54 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { render, screen } from "@testing-library/react";
import React from "react";
import "@testing-library/jest-dom/extend-expect";
import type { CatalogCategorySpec } from "../../../../common/catalog";
import { CatalogCategory } from "../../../../common/catalog";
import { CatalogCategoryLabel } from "../catalog-category-label";
class CatalogCategoryWithoutBadge extends CatalogCategory {
public readonly apiVersion = "catalog.k8slens.dev/v1alpha1";
public readonly kind = "CatalogCategory";
public metadata = {
name: "Test Category",
icon: "",
};
public spec: CatalogCategorySpec = {
group: "entity.k8slens.dev",
versions: [],
names: {
kind: "Test",
},
};
}
class CatalogCategoryWithBadge extends CatalogCategoryWithoutBadge {
getBadge() {
return (<div>Test Badge</div>);
}
}
describe("CatalogCategoryLabel", () => {
it("renders without a badge", async () => {
const category = new CatalogCategoryWithoutBadge();
render(<CatalogCategoryLabel category={category}/>);
expect(await screen.findByText("Test Category")).toBeInTheDocument();
});
it("renders with a badge", async () => {
const category = new CatalogCategoryWithBadge();
render(<CatalogCategoryLabel category={category}/>);
expect(await screen.findByText("Test Category")).toBeInTheDocument();
expect(await screen.findByText("Test Badge")).toBeInTheDocument();
});
});
@@ -0,0 +1,163 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { CatalogCategoryMetadata, CatalogCategorySpec } from "../../../../common/catalog";
import { CatalogEntity, categoryVersion } from "../../../../common/catalog";
import { CatalogCategory } from "../../../api/catalog-entity";
import { noop } from "../../../utils";
import type { CatalogEntityStore } from "../catalog-entity-store/catalog-entity.store";
import { catalogEntityStore } from "../catalog-entity-store/catalog-entity.store";
class TestEntityOne extends CatalogEntity {
public static readonly apiVersion: string = "entity.k8slens.dev/v1alpha1";
public static readonly kind: string = "TestEntityOne";
public readonly apiVersion = TestEntityOne.apiVersion;
public readonly kind = TestEntityOne.kind;
}
class TestEntityTwo extends CatalogEntity {
public static readonly apiVersion: string = "entity.k8slens.dev/v1alpha1";
public static readonly kind: string = "TestEntityTwo";
public readonly apiVersion = TestEntityTwo.apiVersion;
public readonly kind = TestEntityTwo.kind;
}
class TestCategoryOne extends CatalogCategory {
apiVersion = "catalog.k8slens.dev/v1alpha1";
kind = "CatalogCategory";
metadata: CatalogCategoryMetadata = {
icon: "dash",
name: "test-one",
};
spec: CatalogCategorySpec = {
group: "entity.k8slens.dev",
versions: [
categoryVersion("v1alpha1", TestEntityOne),
],
names: {
kind: "KubernetesCluster",
},
};
}
class TestCategoryTwo extends CatalogCategory {
apiVersion = "catalog.k8slens.dev/v1alpha1";
kind = "CatalogCategory";
metadata: CatalogCategoryMetadata = {
icon: "dash",
name: "test-two",
};
spec: CatalogCategorySpec = {
group: "entity.k8slens.dev",
versions: [
categoryVersion("v1alpha1", TestEntityTwo),
],
names: {
kind: "KubernetesCluster",
},
};
}
describe("CatalogEntityStore", () => {
describe("getTotalCount", () => {
let store: CatalogEntityStore;
let testCategoryOne: TestCategoryOne;
let testCategoryTwo: TestCategoryTwo;
beforeEach(() => {
const entityItems = [
new TestEntityOne({
metadata: {
labels: {},
name: "my-test-one",
uid: "1",
},
spec: {},
status: {
phase: "unknown",
},
}),
new TestEntityOne({
metadata: {
labels: {},
name: "my-test-two",
uid: "2",
},
spec: {},
status: {
phase: "unknown",
},
}),
new TestEntityTwo({
metadata: {
labels: {},
name: "my-test-three",
uid: "3",
},
spec: {},
status: {
phase: "unknown",
},
}),
new TestEntityTwo({
metadata: {
labels: {},
name: "my-test-four",
uid: "4",
},
spec: {},
status: {
phase: "unknown",
},
}),
new TestEntityTwo({
metadata: {
labels: {},
name: "my-test-five",
uid: "5",
},
spec: {},
status: {
phase: "unknown",
},
}),
];
testCategoryOne = new TestCategoryOne();
testCategoryTwo = new TestCategoryTwo();
store = catalogEntityStore({
catalogRegistry: {
items: [
testCategoryOne,
testCategoryTwo,
],
},
entityRegistry: {
onRun: noop,
filteredItems: entityItems,
getItemsForCategory: <T extends CatalogEntity>(category: CatalogCategory): T[] => {
return entityItems.filter(item => category.spec.versions.some(version => item instanceof version.entityClass)) as T[];
},
},
});
});
it("given no active category, returns count of all kinds", () => {
expect(store.getTotalCount()).toBe(5);
});
it("given active category is TestCategoryOne, only returns count for those declared kinds", () => {
store.activeCategory.set(testCategoryOne);
expect(store.getTotalCount()).toBe(2);
});
it("given active category is TestCategoryTwo, only returns count for those declared kinds", () => {
store.activeCategory.set(testCategoryTwo);
expect(store.getTotalCount()).toBe(3);
});
});
});
@@ -0,0 +1,137 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { DiContainer } from "@ogre-tools/injectable";
import type { CatalogCategorySpec } from "../../../../common/catalog";
import { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
import { CatalogCategory } from "../../../api/catalog-entity";
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import type { AdditionalCategoryColumnRegistration, CategoryColumnRegistration } from "../custom-category-columns";
import type { CategoryColumns, GetCategoryColumnsParams } from "../columns/get.injectable";
import getCategoryColumnsInjectable from "../columns/get.injectable";
import hotbarStoreInjectable from "../../../../common/hotbars/store.injectable";
import extensionInjectable from "../../../../extensions/extension-loader/extension/extension.injectable";
import currentlyInClusterFrameInjectable from "../../../routes/currently-in-cluster-frame.injectable";
class TestCategory extends CatalogCategory {
apiVersion = "catalog.k8slens.dev/v1alpha1";
kind = "CatalogCategory";
metadata = {
name: "Test",
icon: "question_mark",
};
spec: CatalogCategorySpec = {
group: "foo.bar.bat",
names: {
kind: "Test",
},
versions: [],
};
constructor(columns?: CategoryColumnRegistration[]) {
super();
this.spec = {
displayColumns: columns,
...this.spec,
};
}
}
describe("Custom Category Columns", () => {
let di: DiContainer;
let getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns;
beforeEach(() => {
di = getDiForUnitTesting({ doGeneralOverrides: true });
di.override(hotbarStoreInjectable, () => ({}));
di.override(currentlyInClusterFrameInjectable, () => false);
getCategoryColumns = di.inject(getCategoryColumnsInjectable);
});
describe("without extensions", () => {
it("should contain a kind column if activeCategory is falsy", () => {
expect(getCategoryColumns({ activeCategory: null }).renderTableHeader.find(elem => elem?.title === "Kind")).toBeTruthy();
});
it("should not contain a kind column if activeCategory is truthy", () => {
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "Kind")).toBeFalsy();
});
it("should include the default columns if the provided category doesn't provide any", () => {
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "Source")).toBeTruthy();
});
it("should not include the default columns if the provided category provides any", () => {
expect(getCategoryColumns({ activeCategory: new TestCategory([]) }).renderTableHeader.find(elem => elem?.title === "Source")).toBeFalsy();
});
it("should include the displayColumns from the provided category", () => {
const columns: CategoryColumnRegistration[] = [
{
id: "foo",
renderCell: () => null,
titleProps: {
title: "Foo",
},
},
];
expect(getCategoryColumns({ activeCategory: new TestCategory(columns) }).renderTableHeader.find(elem => elem?.title === "Foo")).toBeTruthy();
});
});
describe("with extensions", () => {
beforeEach(() => {
const ext = di.inject(extensionInjectable, new (class extends LensRendererExtension {
additionalCategoryColumns = [
{
group: "foo.bar.bat",
id: "high",
kind: "Test",
renderCell: () => "",
titleProps: {
title: "High",
},
} as AdditionalCategoryColumnRegistration,
{
group: "foo.bar",
id: "high",
kind: "Test",
renderCell: () => "",
titleProps: {
title: "High2",
},
} as AdditionalCategoryColumnRegistration,
];
})({
absolutePath: "/some-absolute-path",
id: "some-id",
isBundled: false,
isCompatible: true,
isEnabled: true,
manifest: {
engines: {
lens: "",
},
name: "some-extension-name",
version: "1.0.0",
},
manifestPath: "/some-manifest-path",
}));
ext.register();
});
it("should include columns from extensions that match", () => {
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "High")).toBeTruthy();
});
it("should not include columns from extensions that don't match", () => {
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "High2")).toBeFalsy();
});
});
});
@@ -0,0 +1,96 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { DiContainer } from "@ogre-tools/injectable";
import { computed } from "mobx";
import type React from "react";
import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable";
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import type { CustomCategoryViewRegistration } from "../custom-views";
import customCategoryViewsInjectable from "../custom-views.injectable";
describe("Custom Category Views", () => {
let di: DiContainer;
beforeEach(() => {
di = getDiForUnitTesting({ doGeneralOverrides: true });
});
it("should order items correctly over all extensions", () => {
const component1 = (): React.ReactNode => null;
const component2 = (): React.ReactNode => null;
di.override(rendererExtensionsInjectable, () => computed(() => [
{
customCategoryViews: [
{
components: {
View: component1,
},
group: "foo",
kind: "bar",
priority: 100,
} as CustomCategoryViewRegistration,
],
},
{
customCategoryViews: [
{
components: {
View: component2,
},
group: "foo",
kind: "bar",
priority: 95,
} as CustomCategoryViewRegistration,
],
},
] as LensRendererExtension[]));
const customCategoryViews = di.inject(customCategoryViewsInjectable);
const { after = [] } = customCategoryViews.get().get("foo")?.get("bar") ?? {};
expect(after[0].View).toBe(component2);
expect(after[1].View).toBe(component1);
});
it("should put put priority < 50 items in before", () => {
const component1 = (): React.ReactNode => null;
const component2 = (): React.ReactNode => null;
di.override(rendererExtensionsInjectable, () => computed(() => [
{
customCategoryViews: [
{
components: {
View: component1,
},
group: "foo",
kind: "bar",
priority: 40,
} as CustomCategoryViewRegistration,
],
},
{
customCategoryViews: [
{
components: {
View: component2,
},
group: "foo",
kind: "bar",
priority: 95,
} as CustomCategoryViewRegistration,
],
},
] as LensRendererExtension[]));
const customCategoryViews = di.inject(customCategoryViewsInjectable);
const { before = [] } = customCategoryViews.get().get("foo")?.get("bar") ?? {};
expect(before[0].View).toBe(component1);
});
});
@@ -0,0 +1,30 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.CatalogAddButton {
position: absolute;
right: 40px;
bottom: 30px;
.MuiFab-primary {
background-color: var(--blue);
border-radius: 50%;
&:hover {
background-color: #317bb3;
}
}
}
.catalogSpeedDialPopper {
.MuiTooltip-tooltip {
background-color: #222;
font-size: 12px
}
.MuiTooltip-arrow {
color: #222;
}
}
@@ -0,0 +1,150 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./catalog-add-button.scss";
import React from "react";
import { SpeedDial, SpeedDialAction } from "@material-ui/lab";
import { Icon } from "../icon";
import { observer } from "mobx-react";
import { observable, makeObservable, action } from "mobx";
import { autoBind } from "../../../common/utils";
import type { CatalogCategory, CatalogEntityAddMenu } from "../../api/catalog-entity";
import { EventEmitter } from "events";
import type { CatalogCategoryRegistry } from "../../../common/catalog";
import { withInjectables } from "@ogre-tools/injectable-react";
import catalogCategoryRegistryInjectable from "../../../common/catalog/category-registry.injectable";
import type { Navigate } from "../../navigation/navigate.injectable";
import navigateInjectable from "../../navigation/navigate.injectable";
export interface CatalogAddButtonProps {
category: CatalogCategory;
}
type CategoryId = string;
interface Dependencies {
catalogCategoryRegistry: CatalogCategoryRegistry;
navigate: Navigate;
}
@observer
class NonInjectedCatalogAddButton extends React.Component<CatalogAddButtonProps & Dependencies> {
@observable protected isOpen = false;
@observable menuItems = new Map<CategoryId, CatalogEntityAddMenu[]>();
constructor(props: CatalogAddButtonProps & Dependencies) {
super(props);
makeObservable(this);
autoBind(this);
}
componentDidMount() {
this.updateMenuItems();
}
componentDidUpdate(prevProps: CatalogAddButtonProps) {
if (prevProps.category != this.props.category) {
this.updateMenuItems();
}
}
get categories() {
return this.props.catalogCategoryRegistry.filteredItems;
}
@action
updateMenuItems() {
this.menuItems.clear();
if (this.props.category) {
this.updateCategoryItems(this.props.category);
} else {
// Show menu items from all categories
this.categories.forEach(this.updateCategoryItems);
}
}
updateCategoryItems = action((category: CatalogCategory) => {
if (category instanceof EventEmitter) {
const menuItems: CatalogEntityAddMenu[] = [];
category.emit("catalogAddMenu", {
navigate: this.props.navigate,
menuItems,
});
this.menuItems.set(category.getId(), menuItems);
}
});
getCategoryFilteredItems = (category: CatalogCategory) => {
return category.filteredItems(this.menuItems.get(category.getId()) || []);
};
onOpen() {
this.isOpen = true;
}
onClose() {
this.isOpen = false;
}
onButtonClick() {
const defaultAction = this.items.find(item => item.defaultAction)?.onClick;
const clickAction = defaultAction || (this.items.length === 1 ? this.items[0].onClick : null);
clickAction?.();
}
get items() {
const { category } = this.props;
return category ? this.getCategoryFilteredItems(category) :
this.categories.map(this.getCategoryFilteredItems).flat();
}
render() {
if (this.items.length === 0) {
return null;
}
return (
<SpeedDial
className="CatalogAddButton"
ariaLabel="SpeedDial CatalogAddButton"
open={this.isOpen}
onOpen={this.onOpen}
onClose={this.onClose}
icon={<Icon material="add" />}
direction="up"
onClick={this.onButtonClick}
>
{this.items.map((menuItem, index) => {
return (
<SpeedDialAction
key={index}
icon={<Icon material={menuItem.icon}/>}
tooltipTitle={menuItem.title}
onClick={(evt) => {
evt.stopPropagation();
menuItem.onClick();
}}
TooltipClasses={{
popper: "catalogSpeedDialPopper",
}}
/>
);
})}
</SpeedDial>
);
}
}
export const CatalogAddButton = withInjectables<Dependencies, CatalogAddButtonProps>(NonInjectedCatalogAddButton, {
getProps: (di, props) => ({
...props,
catalogCategoryRegistry: di.inject(catalogCategoryRegistryInjectable),
navigate: di.inject(navigateInjectable),
}),
});
@@ -0,0 +1,6 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export const browseCatalogTab = "browse";
@@ -0,0 +1,24 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import React from "react";
import type { CatalogCategory } from "../../../common/catalog/catalog-entity";
interface CatalogCategoryLabelProps {
category: CatalogCategory;
}
/**
* Display label for Catalog Category for the Catalog menu
*/
export const CatalogCategoryLabel = ({ category }: CatalogCategoryLabelProps) => {
const badge = category.getBadge();
return (
<div className="flex">
<div>{category.metadata.name}</div>
{badge ? (<div className="flex items-center">{badge}</div>) : null}
</div>
);
};
@@ -0,0 +1,116 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import React from "react";
import { cssNames, hasDefiniteField } from "../../utils";
import type { MenuActionsProps } from "../menu/menu-actions";
import { MenuActions } from "../menu/menu-actions";
import type { CatalogEntity, CatalogEntityContextMenu } from "../../api/catalog-entity";
import { observer } from "mobx-react";
import { observable } from "mobx";
import { MenuItem } from "../menu";
import { Icon } from "../icon";
import { HotbarToggleMenuItem } from "./hotbar-toggle-menu-item";
import type { VisitEntityContextMenu } from "../../../common/catalog/visit-entity-context-menu.injectable";
import visitEntityContextMenuInjectable from "../../../common/catalog/visit-entity-context-menu.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import type { Navigate } from "../../navigation/navigate.injectable";
import navigateInjectable from "../../navigation/navigate.injectable";
import type { NormalizeCatalogEntityContextMenu } from "../../catalog/normalize-menu-item.injectable";
import normalizeCatalogEntityContextMenuInjectable from "../../catalog/normalize-menu-item.injectable";
export interface CatalogEntityDrawerMenuProps<Entity extends CatalogEntity> extends MenuActionsProps {
entity: Entity;
}
interface Dependencies {
normalizeMenuItem: NormalizeCatalogEntityContextMenu;
navigate: Navigate;
visitEntityContextMenu: VisitEntityContextMenu;
}
@observer
class NonInjectedCatalogEntityDrawerMenu<T extends CatalogEntity> extends React.Component<CatalogEntityDrawerMenuProps<T> & Dependencies> {
private readonly menuItems = observable.array<CatalogEntityContextMenu>();
componentDidMount() {
this.props.visitEntityContextMenu(this.props.entity, {
menuItems: this.menuItems,
navigate: this.props.navigate,
});
}
getMenuItems(entity: T): React.ReactChild[] {
if (!entity) {
return [];
}
const items = this.menuItems
.map(this.props.normalizeMenuItem)
.filter(hasDefiniteField("icon"))
.map(menuItem => (
<MenuItem key={menuItem.title} onClick={menuItem.onClick}>
<Icon
interactive
tooltip={menuItem.title}
{...{ [Icon.isSvg(menuItem.icon) ? "svg" : "material"]: menuItem.icon }}
/>
</MenuItem>
));
items.push(
<HotbarToggleMenuItem
key="hotbar-toggle"
entity={entity}
addContent={(
<Icon
material="push_pin"
interactive
small
tooltip="Add to Hotbar"
/>
)}
removeContent={(
<Icon
svg="push_off"
interactive
small
tooltip="Remove from Hotbar"
/>
)}
/>,
);
return items;
}
render() {
const { className, entity, ...menuProps } = this.props;
if (!this.menuItems.length || !entity.isEnabled()) {
return null;
}
return (
<MenuActions
id={`menu-actions-for-catalog-entity-drawer-menu-${entity.getId()}`}
className={cssNames("CatalogEntityDrawerMenu", className)}
toolbar
{...menuProps}
>
{this.getMenuItems(entity)}
</MenuActions>
);
}
}
export const CatalogEntityDrawerMenu = withInjectables<Dependencies, CatalogEntityDrawerMenuProps<CatalogEntity>>(NonInjectedCatalogEntityDrawerMenu, {
getProps: (di, props) => ({
...props,
visitEntityContextMenu: di.inject(visitEntityContextMenuInjectable),
normalizeMenuItem: di.inject(normalizeCatalogEntityContextMenuInjectable),
navigate: di.inject(navigateInjectable),
}),
}) as <Entity extends CatalogEntity>(props: CatalogEntityDrawerMenuProps<Entity>) => JSX.Element;
@@ -0,0 +1,19 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { catalogEntityStore } from "./catalog-entity.store";
import catalogEntityRegistryInjectable from "../../../api/catalog/entity/registry.injectable";
import catalogCategoryRegistryInjectable from "../../../../common/catalog/category-registry.injectable";
const catalogEntityStoreInjectable = getInjectable({
id: "catalog-entity-store",
instantiate: (di) => catalogEntityStore({
entityRegistry: di.inject(catalogEntityRegistryInjectable),
catalogRegistry: di.inject(catalogCategoryRegistryInjectable),
}),
});
export default catalogEntityStoreInjectable;
@@ -0,0 +1,86 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { IComputedValue, IObservableValue } from "mobx";
import { computed, observable, reaction } from "mobx";
import type { CatalogEntityRegistry } from "../../../api/catalog/entity/registry";
import type { CatalogEntity } from "../../../api/catalog-entity";
import type { CatalogCategory, CatalogCategoryRegistry } from "../../../../common/catalog";
import type { Disposer } from "../../../../common/utils";
import { disposer } from "../../../../common/utils";
import type { ItemListStore } from "../../item-object-list";
type EntityRegistry = Pick<CatalogEntityRegistry, "getItemsForCategory" | "filteredItems" | "onRun">;
type CatalogRegistry = Pick<CatalogCategoryRegistry, "items">;
interface Dependencies {
entityRegistry: EntityRegistry;
catalogRegistry: CatalogRegistry;
}
export type CatalogEntityStore = ItemListStore<CatalogEntity, false> & {
readonly entities: IComputedValue<CatalogEntity[]>;
readonly activeCategory: IObservableValue<CatalogCategory | undefined>;
readonly selectedItemId: IObservableValue<string | undefined>;
readonly selectedItem: IComputedValue<CatalogEntity | undefined>;
watch(): Disposer;
onRun(entity: CatalogEntity): void;
};
export function catalogEntityStore({
entityRegistry,
catalogRegistry,
}: Dependencies): CatalogEntityStore {
const activeCategory = observable.box<CatalogCategory | undefined>(undefined);
const selectedItemId = observable.box<string | undefined>(undefined);
const entities = computed(() => {
const category = activeCategory.get();
return category
? entityRegistry.getItemsForCategory(category, { filtered: true })
: entityRegistry.filteredItems;
});
const selectedItem = computed(() => {
const id = selectedItemId.get();
if (!id) {
return undefined;
}
return entities.get().find(entity => entity.getId() === id);
});
const loadAll = () => {
const category = activeCategory.get();
if (category) {
category.emit("load");
} else {
for (const category of catalogRegistry.items) {
category.emit("load");
}
}
};
return {
entities,
selectedItem,
activeCategory,
selectedItemId,
watch: () => disposer(
reaction(() => entities.get(), loadAll),
reaction(() => activeCategory.get(), loadAll, { delay: 100 }),
),
onRun: entity => entityRegistry.onRun(entity),
failedLoading: false,
getTotalCount: () => entities.get().length,
isLoaded: true,
isSelected: (item) => item.getId() === selectedItemId.get(),
isSelectedAll: () => false,
pickOnlySelected: () => [],
toggleSelection: () => {},
toggleSelectionAll: () => {},
removeSelectedItems: async () => {},
};
}
@@ -0,0 +1,22 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.bordered {
border-top: 1px solid var(--inputControlBorder);
}
.parent {
color: var(--textColorAccent);
font-size: small;
text-transform: uppercase;
font-weight: bold;
}
.catalog {
font-size: medium;
padding: var(--padding) calc(var(--padding) * 1.5);
font-weight: bold;
color: var(--textColorAccent);
}
@@ -0,0 +1,97 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import treeStyles from "./catalog-tree.module.scss";
import styles from "./catalog-menu.module.scss";
import React from "react";
import type { TreeItemProps } from "@material-ui/lab";
import { TreeItem, TreeView } from "@material-ui/lab";
import { Icon } from "../icon";
import { StylesProvider } from "@material-ui/core";
import { cssNames } from "../../utils";
import type { CatalogCategory } from "../../api/catalog-entity";
import { observer } from "mobx-react";
import { CatalogCategoryLabel } from "./catalog-category-label";
import type { IComputedValue } from "mobx";
import { withInjectables } from "@ogre-tools/injectable-react";
import filteredCategoriesInjectable from "../../../common/catalog/filtered-categories.injectable";
export interface CatalogMenuProps {
activeTab: string | undefined;
onItemClick: (id: string) => void;
}
function getCategoryIcon(category: CatalogCategory) {
const { icon } = category.metadata ?? {};
if (typeof icon === "string") {
return Icon.isSvg(icon)
? <Icon small svg={icon}/>
: <Icon small material={icon}/>;
}
return null;
}
function Item(props: TreeItemProps) {
return (
<TreeItem classes={treeStyles} {...props}/>
);
}
interface Dependencies {
filteredCategories: IComputedValue<CatalogCategory[]>;
}
const NonInjectedCatalogMenu = observer(({
activeTab,
filteredCategories,
onItemClick,
}: CatalogMenuProps & Dependencies) => (
// Overwrite Material UI styles with injectFirst https://material-ui.com/guides/interoperability/#controlling-priority-4
<StylesProvider injectFirst>
<div className="flex flex-col w-full">
<div className={styles.catalog}>Catalog</div>
<TreeView
defaultExpanded={["catalog"]}
defaultCollapseIcon={<Icon material="expand_more" />}
defaultExpandIcon={<Icon material="chevron_right" />}
selected={activeTab || "browse"}
>
<Item
nodeId="browse"
label="Browse"
data-testid="*-tab"
onClick={() => onItemClick("*")} />
<Item
nodeId="catalog"
label={<div className={styles.parent}>Categories</div>}
className={cssNames(styles.bordered)}
>
{
filteredCategories.get()
.map(category => (
<Item
icon={getCategoryIcon(category)}
key={category.getId()}
nodeId={category.getId()}
label={<CatalogCategoryLabel category={category} />}
data-testid={`${category.getId()}-tab`}
onClick={() => onItemClick(category.getId())} />
))
}
</Item>
</TreeView>
</div>
</StylesProvider>
));
export const CatalogMenu = withInjectables<Dependencies, CatalogMenuProps>(NonInjectedCatalogMenu, {
getProps: (di, props) => ({
...props,
filteredCategories: di.inject(filteredCategoriesInjectable),
}),
});
@@ -0,0 +1,22 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable";
import { browseCatalogTab } from "../catalog-browse-tab";
const catalogPreviousActiveTabStorageInjectable = getInjectable({
id: "catalog-previous-active-tab-storage",
instantiate: (di) => {
const createStorage = di.inject(createStorageInjectable);
return createStorage<string | null>(
"catalog-previous-active-tab",
browseCatalogTab,
);
},
});
export default catalogPreviousActiveTabStorageInjectable;
@@ -0,0 +1,21 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { Catalog } from "./catalog";
import catalogRouteInjectable from "../../../common/front-end-routing/routes/catalog/catalog-route.injectable";
import { routeSpecificComponentInjectionToken } from "../../routes/route-specific-component-injection-token";
const catalogRouteComponentInjectable = getInjectable({
id: "catalog-route-component",
instantiate: (di) => ({
route: di.inject(catalogRouteInjectable),
Component: Catalog,
}),
injectionToken: routeSpecificComponentInjectionToken,
});
export default catalogRouteComponentInjectable;
@@ -0,0 +1,24 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { computed } from "mobx";
import routePathParametersInjectable from "../../routes/route-path-parameters.injectable";
import catalogRouteInjectable from "../../../common/front-end-routing/routes/catalog/catalog-route.injectable";
const catalogRouteParametersInjectable = getInjectable({
id: "catalog-route-parameters",
instantiate: (di) => {
const route = di.inject(catalogRouteInjectable);
const pathParameters = di.inject(routePathParametersInjectable, route);
return {
group: computed(() => pathParameters.get().group),
kind: computed(() => pathParameters.get().kind),
};
},
});
export default catalogRouteParametersInjectable;
@@ -0,0 +1,61 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
/* This file overwrites basic Material UI TreeView styles. Can contain only following
classnames: root, expanded, selected, content, iconContainer, label, group
*/
.root {
color: var(--textColorTertiary);
}
.label {
font-size: var(--font-size)!important;
background-color: transparent!important;
&:hover {
background-color: transparent;
}
}
.content {
min-height: 26px;
line-height: 1.3;
padding: 2px var(--padding) 2px 0;
&:hover {
background-color: var(--sidebarItemHoverBackground);
border-radius: 2px;
}
&:active {
color: white;
background-color: var(--blue);
}
}
.group {
margin-left: 0px;
.iconContainer {
margin-left: 28px;
margin-top: 2px;
align-self: flex-start;
}
}
.selected {
& > *:first-child {
background-color: var(--blue);
color: white;
border-radius: 2px;
}
}
.iconContainer {
width: 21px;
margin-left: 5px;
margin-right: 0;
}
@@ -0,0 +1,138 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.Catalog {
:global(.TableRow):hover .pinIcon {
opacity: 1;
}
flex-grow: 1;
}
.entityName {
position: relative;
width: 100%;
max-width: 100%;
display: flex;
align-items: center;
padding: 0 var(--padding);
padding-bottom: 0;
padding-right: 24px; // + reserved space for .pinIcon
flex-grow: 2.5!important;
> span {
overflow: hidden;
text-overflow: ellipsis;
padding-left: var(--padding);
}
:global(.HotbarIcon){
align-self: center;
div {
/* icons with plain text */
font-size: var(--unit);
}
.Icon {
/* icons with font-icon */
font-size: var(--small-size);
}
}
.pinIcon {
--color-active: var(--textColorAccent);
transition: none;
opacity: 0;
margin-left: var(--padding);
width: calc(var(--unit) * 2);
&:hover {
/* Drop styles defined for <Icon/> */
background-color: transparent;
box-shadow: none;
}
}
}
.sourceCell {
max-width: 100px;
}
.statusCell {
max-width: 100px;
:global {
.connected, .available {
color: var(--colorSuccess);
}
.disconnected, .deleting, .unavailable {
color: var(--halfGray);
}
}
}
.labelsCell {
overflow-x: scroll;
text-overflow: unset;
}
.labelsCell::-webkit-scrollbar {
display: none;
}
.badge {
overflow: unset;
text-overflow: unset;
max-width: unset;
}
.badge:hover {
color: var(--textColorAccent);
}
.badge:not(:first-child) {
margin-left: 0.5em;
}
.catalogIcon {
font-size: 10px;
-webkit-font-smoothing: auto;
}
.tabs {
@apply flex flex-grow flex-col;
}
.tab {
@apply px-8 py-4;
}
.tab:hover {
background-color: var(--sidebarItemHoverBackground);
--color-active: var(--textColorTertiary);
}
.tab::after {
display: none;
}
.activeTab, .activeTab:hover {
background-color: var(--blue);
--color-active: white;
}
.catalogAvatar {
font-size: 1.2ch;
}
.views {
padding: calc(var(--padding) * 2);
display: flex;
flex-direction: column;
height: 100%;
}
@@ -0,0 +1,229 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import React from "react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Catalog } from "./catalog";
import type { CatalogEntityActionContext, CatalogEntityData } from "../../../common/catalog";
import { CatalogEntity } from "../../../common/catalog";
import type { CatalogEntityOnBeforeRun, CatalogEntityRegistry } from "../../api/catalog/entity/registry";
import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.store";
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import type { DiContainer } from "@ogre-tools/injectable";
import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable";
import catalogEntityRegistryInjectable from "../../api/catalog/entity/registry.injectable";
import type { DiRender } from "../test-utils/renderFor";
import { renderFor } from "../test-utils/renderFor";
import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import getConfigurationFileModelInjectable from "../../../common/get-configuration-file-model/get-configuration-file-model.injectable";
import type { AppEvent } from "../../../common/app-event-bus/event-bus";
import appEventBusInjectable from "../../../common/app-event-bus/app-event-bus.injectable";
import { computed } from "mobx";
import broadcastMessageInjectable from "../../../common/ipc/broadcast-message.injectable";
import type { AsyncFnMock } from "@async-fn/jest";
import asyncFn from "@async-fn/jest";
import { flushPromises } from "../../../common/test-utils/flush-promises";
import userStoreInjectable from "../../../common/user-store/user-store.injectable";
import releaseChannelInjectable from "../../../common/vars/release-channel.injectable";
import defaultUpdateChannelInjectable from "../../../features/application-update/common/selected-update-channel/default-update-channel.injectable";
import currentlyInClusterFrameInjectable from "../../routes/currently-in-cluster-frame.injectable";
class MockCatalogEntity extends CatalogEntity {
public apiVersion = "api";
public kind = "kind";
constructor(data: CatalogEntityData, public onRun: (context: CatalogEntityActionContext) => void | Promise<void>) {
super(data);
}
}
function createMockCatalogEntity(onRun: (context: CatalogEntityActionContext) => void | Promise<void>) {
return new MockCatalogEntity({
metadata: {
uid: "a_catalogEntity_uid",
name: "a catalog entity",
labels: {
test: "label",
},
},
status: {
phase: "",
},
spec: {},
}, onRun);
}
describe("<Catalog />", () => {
let di: DiContainer;
let catalogEntityStore: CatalogEntityStore;
let catalogEntityRegistry: CatalogEntityRegistry;
let appEventListener: jest.MockedFunction<(event: AppEvent) => void>;
let onRun: jest.MockedFunction<(context: CatalogEntityActionContext) => void | Promise<void>>;
let catalogEntityItem: MockCatalogEntity;
let render: DiRender;
beforeEach(async () => {
di = getDiForUnitTesting({ doGeneralOverrides: true });
di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data");
di.override(currentlyInClusterFrameInjectable, () => false);
di.override(broadcastMessageInjectable, () => async () => {});
di.permitSideEffects(getConfigurationFileModelInjectable);
render = renderFor(di);
onRun = jest.fn();
catalogEntityItem = createMockCatalogEntity(onRun);
catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable);
appEventListener = jest.fn();
di.inject(appEventBusInjectable).addListener(appEventListener);
catalogEntityStore = di.inject(catalogEntityStoreInjectable);
Object.assign(catalogEntityStore, {
selectedItem: computed(() => catalogEntityItem),
});
di.override(releaseChannelInjectable, () => ({
get: () => "latest" as const,
init: async () => {},
}));
await di.inject(defaultUpdateChannelInjectable).init();
di.inject(userStoreInjectable).load();
});
describe("can use catalogEntityRegistry.addOnBeforeRun to add hooks for catalog entities", () => {
let onBeforeRunMock: AsyncFnMock<CatalogEntityOnBeforeRun>;
beforeEach(() => {
onBeforeRunMock = asyncFn();
catalogEntityRegistry.addOnBeforeRun(onBeforeRunMock);
render(<Catalog />);
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
});
it("calls on before run event", () => {
const target = onBeforeRunMock.mock.calls[0][0].target;
const actual = { id: target.getId(), name: target.getName() };
expect(actual).toEqual({
id: "a_catalogEntity_uid",
name: "a catalog entity",
});
});
it("does not call onRun yet", () => {
expect(onRun).not.toHaveBeenCalled();
});
it("when before run event resolves, calls onRun", async () => {
await onBeforeRunMock.resolve();
expect(onRun).toHaveBeenCalled();
});
});
it("onBeforeRun prevents event => onRun wont be triggered", async () => {
const onBeforeRunMock = jest.fn((event) => event.preventDefault());
catalogEntityRegistry.addOnBeforeRun(onBeforeRunMock);
render(<Catalog />);
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
await flushPromises();
expect(onRun).not.toHaveBeenCalled();
});
it("addOnBeforeRun throw an exception => onRun will be triggered", async () => {
const onBeforeRunMock = jest.fn(() => {
throw new Error("some error");
});
catalogEntityRegistry.addOnBeforeRun(onBeforeRunMock);
render(<Catalog />);
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
await flushPromises();
expect(onRun).toHaveBeenCalled();
});
it("addOnRunHook return a promise and does not prevent run event => onRun()", (done) => {
onRun.mockImplementation(() => done());
catalogEntityRegistry.addOnBeforeRun(
async () => {
// no op
},
);
render(<Catalog />);
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
});
it("addOnRunHook return a promise and prevents event wont be triggered", async () => {
const onBeforeRunMock = asyncFn();
catalogEntityRegistry.addOnBeforeRun(onBeforeRunMock);
render(<Catalog />);
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
onBeforeRunMock.mock.calls[0][0].preventDefault();
await onBeforeRunMock.resolve();
expect(onRun).not.toHaveBeenCalled();
});
it("addOnRunHook return a promise and reject => onRun will be triggered", async () => {
const onBeforeRunMock = asyncFn();
catalogEntityRegistry.addOnBeforeRun(onBeforeRunMock);
render(<Catalog />);
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
await onBeforeRunMock.reject();
expect(onRun).toHaveBeenCalled();
});
it("emits catalog open AppEvent", () => {
render(<Catalog />);
expect(appEventListener).toHaveBeenCalledWith( {
action: "open",
name: "catalog",
});
});
it("emits catalog change AppEvent when changing the category", () => {
render(<Catalog />);
userEvent.click(screen.getByText("Web Links"));
expect(appEventListener).toHaveBeenCalledWith({
action: "change-category",
name: "catalog",
params: {
category: "Web Links",
},
});
});
});
@@ -0,0 +1,375 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./catalog.module.scss";
import React from "react";
import { disposeOnUnmount, observer } from "mobx-react";
import { ItemListLayout } from "../item-object-list";
import type { IComputedValue } from "mobx";
import { action, computed, makeObservable, observable, reaction, runInAction, when } from "mobx";
import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.store";
import { MenuItem, MenuActions } from "../menu";
import type { CatalogEntityContextMenu } from "../../api/catalog-entity";
import type { CatalogCategory, CatalogCategoryRegistry, CatalogEntity } from "../../../common/catalog";
import { CatalogAddButton } from "./catalog-add-button";
import type { ShowNotification } from "../notifications";
import { MainLayout } from "../layout/main-layout";
import type { StorageLayer } from "../../utils";
import { prevDefault } from "../../utils";
import { CatalogEntityDetails } from "./entity-details/view";
import { CatalogMenu } from "./catalog-menu";
import { RenderDelay } from "../render-delay/render-delay";
import { Icon } from "../icon";
import { HotbarToggleMenuItem } from "./hotbar-toggle-menu-item";
import { Avatar } from "../avatar";
import { withInjectables } from "@ogre-tools/injectable-react";
import catalogPreviousActiveTabStorageInjectable from "./catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable";
import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable";
import type { GetCategoryColumnsParams, CategoryColumns } from "./columns/get.injectable";
import getCategoryColumnsInjectable from "./columns/get.injectable";
import type { RegisteredCustomCategoryViewDecl } from "./custom-views.injectable";
import customCategoryViewsInjectable from "./custom-views.injectable";
import type { CustomCategoryViewComponents } from "./custom-views";
import type { NavigateToCatalog } from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";
import navigateToCatalogInjectable from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";
import catalogRouteParametersInjectable from "./catalog-route-parameters.injectable";
import { browseCatalogTab } from "./catalog-browse-tab";
import hotbarStoreInjectable from "../../../common/hotbars/store.injectable";
import type { HotbarStore } from "../../../common/hotbars/store";
import type { VisitEntityContextMenu } from "../../../common/catalog/visit-entity-context-menu.injectable";
import catalogCategoryRegistryInjectable from "../../../common/catalog/category-registry.injectable";
import visitEntityContextMenuInjectable from "../../../common/catalog/visit-entity-context-menu.injectable";
import type { Navigate } from "../../navigation/navigate.injectable";
import navigateInjectable from "../../navigation/navigate.injectable";
import type { NormalizeCatalogEntityContextMenu } from "../../catalog/normalize-menu-item.injectable";
import normalizeCatalogEntityContextMenuInjectable from "../../catalog/normalize-menu-item.injectable";
import type { EmitAppEvent } from "../../../common/app-event-bus/emit-event.injectable";
import emitAppEventInjectable from "../../../common/app-event-bus/emit-event.injectable";
import type { Logger } from "../../../common/logger";
import loggerInjectable from "../../../common/logger.injectable";
import showErrorNotificationInjectable from "../notifications/show-error-notification.injectable";
interface Dependencies {
catalogPreviousActiveTabStorage: StorageLayer<string | null>;
catalogEntityStore: CatalogEntityStore;
getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns;
customCategoryViews: IComputedValue<Map<string, Map<string, RegisteredCustomCategoryViewDecl>>>;
emitEvent: EmitAppEvent;
routeParameters: {
group: IComputedValue<string>;
kind: IComputedValue<string>;
};
navigateToCatalog: NavigateToCatalog;
hotbarStore: HotbarStore;
catalogCategoryRegistry: CatalogCategoryRegistry;
visitEntityContextMenu: VisitEntityContextMenu;
navigate: Navigate;
normalizeMenuItem: NormalizeCatalogEntityContextMenu;
showErrorNotification: ShowNotification;
logger: Logger;
}
@observer
class NonInjectedCatalog extends React.Component<Dependencies> {
private readonly menuItems = observable.array<CatalogEntityContextMenu>();
@observable activeTab: string | undefined = undefined;
constructor(props: Dependencies) {
super(props);
makeObservable(this);
}
@computed
get routeActiveTab(): string {
const { routeParameters: { group, kind }, catalogPreviousActiveTabStorage } = this.props;
const dereferencedGroup = group.get();
const dereferencedKind = kind.get();
if (dereferencedGroup && dereferencedKind) {
return `${dereferencedGroup}/${dereferencedKind}`;
}
return catalogPreviousActiveTabStorage.get() || browseCatalogTab;
}
async componentDidMount() {
const {
catalogEntityStore,
catalogPreviousActiveTabStorage,
catalogCategoryRegistry,
logger,
showErrorNotification,
} = this.props;
disposeOnUnmount(this, [
catalogEntityStore.watch(),
reaction(() => this.routeActiveTab, async (routeTab) => {
catalogPreviousActiveTabStorage.set(this.routeActiveTab);
try {
if (routeTab !== browseCatalogTab) {
// we need to wait because extensions might take a while to load
await when(() => Boolean(catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab)), { timeout: 5_000 });
}
const item = catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab);
runInAction(() => {
this.activeTab = routeTab;
catalogEntityStore.activeCategory.set(item);
});
} catch (error) {
logger.warn("Failed to find route tab", error);
showErrorNotification((
<p>
{"Unknown category: "}
{routeTab}
</p>
));
}
}, { fireImmediately: true }),
// If active category is filtered out, automatically switch to the first category
reaction(() => catalogCategoryRegistry.filteredItems, () => {
if (!catalogCategoryRegistry.filteredItems.find(item => item.getId() === catalogEntityStore.activeCategory.get()?.getId())) {
const item = catalogCategoryRegistry.filteredItems[0];
runInAction(() => {
if (item) {
this.activeTab = item.getId();
this.props.catalogEntityStore.activeCategory.set(item);
}
});
}
}),
]);
this.props.emitEvent({
name: "catalog",
action: "open",
});
}
addToHotbar(entity: CatalogEntity): void {
this.props.hotbarStore.addToHotbar(entity);
}
removeFromHotbar(entity: CatalogEntity): void {
this.props.hotbarStore.removeFromHotbar(entity.getId());
}
onDetails = (entity: CatalogEntity) => {
if (this.props.catalogEntityStore.selectedItemId.get()) {
this.props.catalogEntityStore.selectedItemId.set(undefined);
} else {
this.props.catalogEntityStore.onRun(entity);
}
};
get categories() {
return this.props.catalogCategoryRegistry.items;
}
onTabChange = action((tabId: string | null) => {
const activeCategory = this.categories.find(category => category.getId() === tabId);
this.props.emitEvent({
name: "catalog",
action: "change-category",
params: {
category: activeCategory ? activeCategory.getName() : "Browse",
},
});
if (activeCategory) {
this.props.catalogPreviousActiveTabStorage.set(`${activeCategory.spec.group}/${activeCategory.spec.names.kind}`);
this.props.navigateToCatalog({ group: activeCategory.spec.group, kind: activeCategory.spec.names.kind });
} else {
this.props.catalogPreviousActiveTabStorage.set(null);
this.props.navigateToCatalog({ group: browseCatalogTab });
}
});
renderItemMenu = (entity: CatalogEntity) => {
const onOpen = () => {
this.menuItems.clear();
this.props.visitEntityContextMenu(entity, {
menuItems: this.menuItems,
navigate: this.props.navigate,
});
};
return (
<MenuActions
id={`menu-actions-for-catalog-for-${entity.getId()}`}
data-testid={`menu-actions-for-catalog-for-${entity.getId()}`}
onOpen={onOpen}
>
<MenuItem
key="open-details"
data-testid={`open-details-menu-item-for-${entity.getId()}`}
onClick={() => this.props.catalogEntityStore.selectedItemId.set(entity.getId())}
>
View Details
</MenuItem>
{
this.menuItems
.map(this.props.normalizeMenuItem)
.map((menuItem, index) => (
<MenuItem key={index} onClick={menuItem.onClick}>
{menuItem.title}
</MenuItem>
))
}
<HotbarToggleMenuItem
key="hotbar-toggle"
entity={entity}
addContent="Add to Hotbar"
removeContent="Remove from Hotbar"
/>
</MenuActions>
);
};
renderName(entity: CatalogEntity) {
const isItemInHotbar = this.props.hotbarStore.isAddedToActive(entity);
return (
<>
<Avatar
title={entity.getName()}
colorHash={`${entity.getName()}-${entity.getSource()}`}
src={entity.spec.icon?.src}
background={entity.spec.icon?.background}
className={styles.catalogAvatar}
size={24}
>
{entity.spec.icon?.material && <Icon material={entity.spec.icon?.material} small/>}
</Avatar>
<span>{entity.getName()}</span>
<Icon
small
className={styles.pinIcon}
svg={isItemInHotbar ? "push_off" : "push_pin"}
tooltip={isItemInHotbar ? "Remove from Hotbar" : "Add to Hotbar"}
onClick={prevDefault(() => isItemInHotbar ? this.removeFromHotbar(entity) : this.addToHotbar(entity))}
/>
</>
);
}
renderViews = (activeCategory: CatalogCategory | undefined) => {
if (!activeCategory) {
return this.renderList(undefined);
}
const customViews = this.props.customCategoryViews.get()
.get(activeCategory.spec.group)
?.get(activeCategory.spec.names.kind);
const renderView = ({ View }: CustomCategoryViewComponents, index: number) => (
<View
key={index}
category={activeCategory}
/>
);
return (
<>
{customViews?.before.map(renderView)}
{this.renderList(activeCategory)}
{customViews?.after.map(renderView)}
</>
);
};
renderList(activeCategory: CatalogCategory | undefined) {
const { catalogEntityStore, getCategoryColumns } = this.props;
const tableId = activeCategory
? `catalog-items-${activeCategory.metadata.name.replace(" ", "")}`
: "catalog-items";
if (this.activeTab === undefined) {
return null;
}
return (
<ItemListLayout<CatalogEntity, false>
className={styles.Catalog}
tableId={tableId}
renderHeaderTitle={activeCategory?.metadata.name ?? "Browse All"}
isSelectable={false}
isConfigurable={true}
preloadStores={false}
store={catalogEntityStore}
getItems={() => catalogEntityStore.entities.get()}
customizeTableRowProps={entity => ({
disabled: !entity.isEnabled(),
})}
{...getCategoryColumns({ activeCategory })}
onDetails={this.onDetails}
renderItemMenu={this.renderItemMenu}
data-testid={`catalog-list-for-${activeCategory?.metadata.name ?? "browse-all"}`}
/>
);
}
render() {
const activeCategory = this.props.catalogEntityStore.activeCategory.get();
const selectedItem = this.props.catalogEntityStore.selectedItem.get();
return (
<MainLayout
sidebar={(
<CatalogMenu
activeTab={this.activeTab}
onItemClick={this.onTabChange}
/>
)}
>
<div className={styles.views}>
{this.renderViews(activeCategory)}
</div>
{
selectedItem
? (
<CatalogEntityDetails
entity={selectedItem}
hideDetails={() => this.props.catalogEntityStore.selectedItemId.set(undefined)}
onRun={() => this.props.catalogEntityStore.onRun(selectedItem)}
/>
)
: activeCategory
? (
<RenderDelay>
<CatalogAddButton category={activeCategory} />
</RenderDelay>
)
: null
}
</MainLayout>
);
}
}
export const Catalog = withInjectables<Dependencies>(NonInjectedCatalog, {
getProps: (di, props) => ({
...props,
catalogEntityStore: di.inject(catalogEntityStoreInjectable),
catalogPreviousActiveTabStorage: di.inject(catalogPreviousActiveTabStorageInjectable),
getCategoryColumns: di.inject(getCategoryColumnsInjectable),
customCategoryViews: di.inject(customCategoryViewsInjectable),
routeParameters: di.inject(catalogRouteParametersInjectable),
navigateToCatalog: di.inject(navigateToCatalogInjectable),
emitEvent: di.inject(emitAppEventInjectable),
hotbarStore: di.inject(hotbarStoreInjectable),
catalogCategoryRegistry: di.inject(catalogCategoryRegistryInjectable),
visitEntityContextMenu: di.inject(visitEntityContextMenuInjectable),
navigate: di.inject(navigateInjectable),
normalizeMenuItem: di.inject(normalizeCatalogEntityContextMenuInjectable),
logger: di.inject(loggerInjectable),
showErrorNotification: di.inject(showErrorNotificationInjectable),
}),
});
@@ -0,0 +1,35 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns";
import { getInjectable } from "@ogre-tools/injectable";
import namedCategoryColumnInjectable from "./named-category.injectable";
import defaultCategoryColumnsInjectable from "./default-category.injectable";
const defaultBrowseAllColumns: RegisteredAdditionalCategoryColumn[] = [
{
id: "kind",
priority: 5,
renderCell: entity => entity.kind,
titleProps: {
id: "kind",
sortBy: "kind",
title: "Kind",
},
sortCallback: entity => entity.kind,
},
];
const browseAllColumnsInjectable = getInjectable({
id: "browse-all-columns",
instantiate: (di) => [
...defaultBrowseAllColumns,
di.inject(namedCategoryColumnInjectable),
...di.inject(defaultCategoryColumnsInjectable),
],
});
export default browseAllColumnsInjectable;
@@ -0,0 +1,17 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectionToken } from "@ogre-tools/injectable";
import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns";
export interface CustomCatalogCategoryRegistration {
kind: string;
group: string;
registration: RegisteredAdditionalCategoryColumn;
}
export const customCatalogCategoryColumnInjectionToken = getInjectionToken<CustomCatalogCategoryRegistration>({
id: "custom-catalog-category-column-token",
});
@@ -0,0 +1,64 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "../catalog.module.scss";
import React from "react";
import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns";
import { KubeObject } from "../../../../common/k8s-api/kube-object";
import { getInjectable } from "@ogre-tools/injectable";
import getLabelBadgesInjectable from "../get-label-badges.injectable";
const defaultCategoryColumnsInjectable = getInjectable({
id: "default-category-columns",
instantiate: (di): RegisteredAdditionalCategoryColumn[] => {
const getLabelBadges = di.inject(getLabelBadgesInjectable);
return [
{
id: "source",
priority: 10,
renderCell: entity => entity.getSource(),
titleProps: {
title: "Source",
className: styles.sourceCell,
id: "source",
sortBy: "source",
},
sortCallback: entity => entity.getSource(),
searchFilter: entity => `source=${entity.getSource()}`,
},
{
id: "labels",
priority: 20,
renderCell: getLabelBadges,
titleProps: {
id: "labels",
title: "Labels",
className: `${styles.labelsCell} scrollable`,
},
searchFilter: entity => KubeObject.stringifyLabels(entity.metadata.labels),
},
{
id: "status",
priority: 30,
renderCell: entity => (
<span key="phase" className={entity.status.phase}>
{entity.status.phase}
</span>
),
titleProps: {
title: "Status",
className: styles.statusCell,
id: "status",
sortBy: "status",
},
searchFilter: entity => entity.status.phase,
sortCallback: entity => entity.status.phase,
},
];
},
});
export default defaultCategoryColumnsInjectable;
@@ -0,0 +1,55 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { extensionRegistratorInjectionToken } from "../../../../extensions/extension-loader/extension-registrator-injection-token";
import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
import type { AdditionalCategoryColumnRegistration } from "../custom-category-columns";
import { customCatalogCategoryColumnInjectionToken } from "./custom-token";
const customCategoryColumnsRegistratorInjectable = getInjectable({
id: "custom-category-columns-registrator",
instantiate: () => (ext) => {
const extension = ext as LensRendererExtension;
return extension.additionalCategoryColumns.map(getInjectableForColumnRegistrationFor(extension));
},
injectionToken: extensionRegistratorInjectionToken,
});
export default customCategoryColumnsRegistratorInjectable;
const getInjectableForColumnRegistrationFor = (extension: LensRendererExtension) => ({
group,
id,
kind,
renderCell,
titleProps,
priority = 50,
searchFilter,
sortCallback,
}: AdditionalCategoryColumnRegistration) => {
return getInjectable({
id: `${extension.manifest.name}:${group}/${kind}:${id}`,
instantiate: () => ({
group,
kind,
registration: {
renderCell,
priority,
id,
titleProps: {
id,
...titleProps,
sortBy: sortCallback
? id
: undefined,
},
searchFilter,
sortCallback,
},
}),
injectionToken: customCatalogCategoryColumnInjectionToken,
});
};
@@ -0,0 +1,66 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { orderBy } from "lodash";
import type { CatalogCategory, CatalogEntity } from "../../../../common/catalog";
import type { ItemListLayoutProps } from "../../item-object-list";
import browseAllColumnsInjectable from "./browse-all.injectable";
import getColumnsForCategoryInjectable from "./specific-category.injectable";
export interface GetCategoryColumnsParams {
activeCategory: CatalogCategory | null | undefined;
}
export type CategoryColumns = Required<Pick<ItemListLayoutProps<CatalogEntity>, "sortingCallbacks" | "searchFilters" | "renderTableContents" | "renderTableHeader">>;
export type GetCategoryColumns = (params: GetCategoryColumnsParams) => CategoryColumns;
const getCategoryColumnsInjectable = getInjectable({
id: "get-category-columns",
instantiate: (di): GetCategoryColumns => {
const getColumnsForCategory = di.inject(getColumnsForCategoryInjectable);
const browseAllColumns = di.inject(browseAllColumnsInjectable);
return ({ activeCategory }) => {
const allRegistrations = orderBy(
activeCategory
? getColumnsForCategory(activeCategory)
: browseAllColumns,
"priority",
"asc",
);
const sortingCallbacks: CategoryColumns["sortingCallbacks"] = {};
const searchFilters: CategoryColumns["searchFilters"] = [];
const renderTableHeader: CategoryColumns["renderTableHeader"] = [];
const tableRowRenderers: ((entity: CatalogEntity) => React.ReactNode)[] = [];
for (const registration of allRegistrations) {
if (registration.sortCallback) {
sortingCallbacks[registration.id] = registration.sortCallback;
}
if (registration.searchFilter) {
searchFilters.push(registration.searchFilter);
}
tableRowRenderers.push(registration.renderCell);
renderTableHeader.push({
id: registration.id,
...registration.titleProps,
});
}
return {
sortingCallbacks,
renderTableHeader,
renderTableContents: entity => tableRowRenderers.map(fn => fn(entity)),
searchFilters,
};
};
},
});
export default getCategoryColumnsInjectable;
@@ -0,0 +1,35 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import React from "react";
import type { KubernetesCluster } from "../../../../common/catalog-entities";
import { customCatalogCategoryColumnInjectionToken } from "./custom-token";
const kubernetesApiVersionColumnInjectable = getInjectable({
id: "kubernetes-api-version-column",
instantiate: () => ({
group: "entity.k8slens.dev",
kind: "KubernetesCluster",
registration: {
id: "version",
priority: 30,
renderCell: entity => {
const k8sVersion = (entity as KubernetesCluster).metadata.kubeVersion;
return (
<span key="version">
{k8sVersion === "unknown" ? "" : k8sVersion}
</span>
);
},
titleProps: {
title: "Version",
},
},
}),
injectionToken: customCatalogCategoryColumnInjectionToken,
});
export default kubernetesApiVersionColumnInjectable;
@@ -0,0 +1,36 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import React from "react";
import type { KubernetesCluster } from "../../../../common/catalog-entities";
import { customCatalogCategoryColumnInjectionToken } from "./custom-token";
const kubernetesDistributionColumnInjectable = getInjectable({
id: "kubernetes-distribution-column",
instantiate: () => ({
group: "entity.k8slens.dev",
kind: "KubernetesCluster",
registration: {
id: "distro",
priority: 30,
renderCell: entity => {
const k8sDistro = (entity as KubernetesCluster).metadata.distro;
return (
<span key="distro">
{k8sDistro === "unknown" ? "" : k8sDistro}
</span>
);
},
titleProps: {
title: "Distro",
},
},
}),
injectionToken: customCatalogCategoryColumnInjectionToken,
});
export default kubernetesDistributionColumnInjectable;
@@ -0,0 +1,65 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import styles from "../catalog.module.scss";
import type { CatalogEntity } from "../../../../common/catalog";
import { prevDefault } from "../../../utils";
import { Avatar } from "../../avatar";
import { Icon } from "../../icon";
import React from "react";
import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns";
import hotbarStoreInjectable from "../../../../common/hotbars/store.injectable";
import type { HotbarStore } from "../../../../common/hotbars/store";
const renderEntityName = (hotbarStore: HotbarStore) => (entity: CatalogEntity) => {
const isItemInHotbar = hotbarStore.isAddedToActive(entity);
const onClick = prevDefault(
isItemInHotbar
? () => hotbarStore.removeFromHotbar(entity.getId())
: () => hotbarStore.addToHotbar(entity),
);
return (
<>
<Avatar
title={entity.getName()}
colorHash={`${entity.getName()}-${entity.getSource()}`}
src={entity.spec.icon?.src}
background={entity.spec.icon?.background}
className={styles.catalogAvatar}
size={24}
>
{entity.spec.icon?.material && <Icon material={entity.spec.icon?.material} small/>}
</Avatar>
<span>{entity.getName()}</span>
<Icon
small
className={styles.pinIcon}
svg={isItemInHotbar ? "push_off" : "push_pin"}
tooltip={isItemInHotbar ? "Remove from Hotbar" : "Add to Hotbar"}
onClick={onClick}
/>
</>
);
};
const namedCategoryColumnInjectable = getInjectable({
id: "name-category-column",
instantiate: (di): RegisteredAdditionalCategoryColumn => ({
id: "name",
priority: 0,
renderCell: renderEntityName(di.inject(hotbarStoreInjectable)),
titleProps: {
title: "Name",
className: styles.entityName,
id: "name",
sortBy: "name",
},
searchFilter: (entity) => entity.getName(),
sortCallback: (entity) => `name=${entity.getName()}`,
}),
});
export default namedCategoryColumnInjectable;
@@ -0,0 +1,43 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { CatalogCategory } from "../../../api/catalog-entity";
import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns";
import categoryColumnsInjectable from "../custom-category-columns.injectable";
import defaultCategoryColumnsInjectable from "./default-category.injectable";
import namedCategoryColumnInjectable from "./named-category.injectable";
export type GetColumnsForCategory = (activeCategory: CatalogCategory) => RegisteredAdditionalCategoryColumn[];
const getColumnsForCategoryInjectable = getInjectable({
id: "get-columns-for-category",
instantiate: (di): GetColumnsForCategory => {
const extensionColumns = di.inject(categoryColumnsInjectable);
const defaultCategoryColumns = di.inject(defaultCategoryColumnsInjectable);
const nameCategoryColumn = di.inject(namedCategoryColumnInjectable);
return (activeCategory) => {
const fromExtensions = (
extensionColumns
.get()
.get(activeCategory.spec.group)
?.get(activeCategory.spec.names.kind)
?? []
);
const fromCategory = activeCategory.spec.displayColumns?.map(({ priority = 50, ...column }) => ({
priority,
...column,
})) ?? defaultCategoryColumns;
return [
nameCategoryColumn,
...fromExtensions,
...fromCategory,
];
};
},
});
export default getColumnsForCategoryInjectable;
@@ -0,0 +1,33 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { computedInjectManyInjectable } from "@ogre-tools/injectable-extension-for-mobx";
import { computed } from "mobx";
import { getOrInsert, getOrInsertMap } from "../../utils";
import { customCatalogCategoryColumnInjectionToken } from "./columns/custom-token";
import type { RegisteredAdditionalCategoryColumn } from "./custom-category-columns";
const categoryColumnsInjectable = getInjectable({
id: "category-columns",
instantiate: (di) => {
const computedInjectMany = di.inject(computedInjectManyInjectable);
const columnRegistrations = computedInjectMany(customCatalogCategoryColumnInjectionToken);
return computed(() => {
const res = new Map<string, Map<string, RegisteredAdditionalCategoryColumn[]>>();
for (const { group, kind, registration } of columnRegistrations.get()) {
const byGroup = getOrInsertMap(res, group);
const byKind = getOrInsert(byGroup, kind, []);
byKind.push(registration);
}
return res;
});
},
});
export default categoryColumnsInjectable;
@@ -0,0 +1,85 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type React from "react";
import type { CatalogEntity } from "../../../common/catalog";
import type { TableCellProps } from "../table";
/**
* These are the supported props for the title cell
*/
export interface TitleCellProps {
className?: string;
title: string;
}
export interface CategoryColumnRegistration {
/**
* The sorting order value.
*
* @default 50
*/
priority?: number;
/**
* This value MUST to be unique to your extension
*/
id: string;
/**
* This function will be called to generate the cells (on demand) for the column
*/
renderCell: (entity: CatalogEntity) => React.ReactNode;
/**
* This function will be used to generate the columns title cell.
*/
titleProps: TitleCellProps;
/**
* If provided then the column will support sorting and this function will be called to
* determine a row's ordering.
*
* strings are sorted ahead of numbers, and arrays determine ordering between equal
* elements of the previous index.
*/
sortCallback?: (entity: CatalogEntity) => string | number | (string | number)[];
/**
* If provided then searching is supported on this column and this function will be called
* to determine if the current search string matches for this row.
*/
searchFilter?: (entity: CatalogEntity) => string | string[];
}
/**
* This is the type used to declare new catalog category columns
*/
export interface AdditionalCategoryColumnRegistration extends CategoryColumnRegistration {
/**
* The catalog entity kind that is declared by the category for this registration
*
* e.g.
* - `"KubernetesCluster"`
*/
kind: string;
/**
* The catalog entity group that is declared by the category for this registration
*
* e.g.
* - `"entity.k8slens.dev"`
*/
group: string;
}
export interface RegisteredAdditionalCategoryColumn {
id: string;
priority: number;
renderCell: (entity: CatalogEntity) => React.ReactNode;
titleProps: TableCellProps;
sortCallback?: (entity: CatalogEntity) => string | number | (string | number)[];
searchFilter?: (entity: CatalogEntity) => string | string[];
}
@@ -0,0 +1,60 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { orderBy } from "lodash";
import type { IComputedValue } from "mobx";
import { computed } from "mobx";
import type { LensRendererExtension } from "../../../extensions/lens-renderer-extension";
import rendererExtensionsInjectable from "../../../extensions/renderer-extensions.injectable";
import { getOrInsert, getOrInsertMap } from "../../utils";
import type { CustomCategoryViewComponents } from "./custom-views";
interface Dependencies {
extensions: IComputedValue<LensRendererExtension[]>;
}
export interface RegisteredCustomCategoryViewDecl {
/**
* The asc sorted list of items with priority set to < 50
*/
before: CustomCategoryViewComponents[];
/**
* The asc sorted list of items with priority not set or set to >= 50
*/
after: CustomCategoryViewComponents[];
}
function getCustomCategoryViews({ extensions }: Dependencies): IComputedValue<Map<string, Map<string, RegisteredCustomCategoryViewDecl>>> {
return computed(() => {
const res = new Map<string, Map<string, RegisteredCustomCategoryViewDecl>>();
const registrations = extensions.get()
.flatMap(ext => ext.customCategoryViews)
.map(({ priority = 50, ...rest }) => ({ priority, ...rest }));
const sortedRegistrations = orderBy(registrations, "priority", "asc");
for (const { priority, group, kind, components } of sortedRegistrations) {
const byGroup = getOrInsertMap(res, group);
const { before, after } = getOrInsert(byGroup, kind, { before: [], after: [] });
if (priority < 50) {
before.push(components);
} else {
after.push(components);
}
}
return res;
});
}
const customCategoryViewsInjectable = getInjectable({
id: "custom-category-views",
instantiate: (di) => getCustomCategoryViews({
extensions: di.inject(rendererExtensionsInjectable),
}),
});
export default customCategoryViewsInjectable;
@@ -0,0 +1,57 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type React from "react";
import type { CatalogCategory } from "../../api/catalog-entity";
/**
* The props for CustomCategoryViewComponents.View
*/
export interface CustomCategoryViewProps {
/**
* The category instance itself
*/
category: CatalogCategory;
}
/**
* The components for the category view.
*/
export interface CustomCategoryViewComponents {
View: React.ComponentType<CustomCategoryViewProps>;
}
/**
* This is the type used to declare additional views for a specific category
*/
export interface CustomCategoryViewRegistration {
/**
* The catalog entity kind that is declared by the category for this registration
*
* e.g.
* - `"KubernetesCluster"`
*/
kind: string;
/**
* The catalog entity group that is declared by the category for this registration
*
* e.g.
* - `"entity.k8slens.dev"`
*/
group: string;
/**
* The sorting order value. Used to determine the total order of the views.
*
* @default 50
*/
priority?: number;
/**
* The components for this registration
*/
components: CustomCategoryViewComponents;
}
@@ -0,0 +1,33 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { computedInjectManyInjectable } from "@ogre-tools/injectable-extension-for-mobx";
import { computed } from "mobx";
import { byOrderNumber } from "../../../../common/utils/composable-responsibilities/orderable/orderable";
import type { CatalogEntity } from "../../../api/catalog-entity";
import { catalogEntityDetailItemInjectionToken } from "./token";
const catalogEntityDetailItemsInjectable = getInjectable({
id: "catalog-entity-detail-items",
instantiate: (di, entity) => {
const computedInjectMany = di.inject(computedInjectManyInjectable);
const detailItems = computedInjectMany(catalogEntityDetailItemInjectionToken);
return computed(() => (
detailItems.get()
.filter(item => (
item.apiVersions.has(entity.apiVersion)
&& item.kind === entity.kind
))
.sort(byOrderNumber)
.map(item => item.components.Details)
));
},
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, entity: CatalogEntity) => `${entity.apiVersion}/${entity.kind}`,
}),
});
export default catalogEntityDetailItemsInjectable;
@@ -0,0 +1,39 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import React from "react";
import { KubernetesCluster } from "../../../../../common/catalog-entities";
import { DrawerTitle, DrawerItem } from "../../../drawer";
import { catalogEntityDetailItemInjectionToken } from "../token";
const kubernetesClusterDetailsItemInjectable = getInjectable({
id: "kubernetes-cluster-details-item",
instantiate: () => ({
apiVersions: new Set([KubernetesCluster.apiVersion]),
kind: KubernetesCluster.kind,
orderNumber: 40,
components: {
Details: ({ entity }) => (
<>
<DrawerTitle>Kubernetes Information</DrawerTitle>
<div className="box grow EntityMetadata">
<DrawerItem
name="Distribution"
data-testid={`kubernetes-distro-for-${entity.getId()}`}
>
{entity.metadata.distro || "unknown"}
</DrawerItem>
<DrawerItem name="Kubelet Version">
{entity.metadata.kubeVersion || "unknown"}
</DrawerItem>
</div>
</>
),
},
}),
injectionToken: catalogEntityDetailItemInjectionToken,
});
export default kubernetesClusterDetailsItemInjectable;
@@ -0,0 +1,34 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import React from "react";
import { WebLink } from "../../../../../common/catalog-entities";
import { DrawerTitle, DrawerItem } from "../../../drawer";
import { catalogEntityDetailItemInjectionToken } from "../token";
const weblinkDetailsItemInjectable = getInjectable({
id: "weblink-details-item",
instantiate: () => ({
apiVersions: new Set([WebLink.apiVersion]),
kind: WebLink.kind,
components: {
Details: ({ entity }) => (
<>
<DrawerTitle>More Information</DrawerTitle>
<DrawerItem
name="URL"
data-testid={`weblink-url-for-${entity.getId()}`}
>
{entity.spec.url}
</DrawerItem>
</>
),
},
orderNumber: 40,
}),
injectionToken: catalogEntityDetailItemInjectionToken,
});
export default weblinkDetailsItemInjectable;
@@ -0,0 +1,39 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { extensionRegistratorInjectionToken } from "../../../../extensions/extension-loader/extension-registrator-injection-token";
import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
import type { CatalogEntity } from "../../../api/catalog-entity";
import { getRandId } from "../../../utils";
import type { CatalogEntityDetailRegistration } from "./token";
import { catalogEntityDetailItemInjectionToken } from "./token";
const catalogEntityDetailItemsRegistratorInjectable = getInjectable({
id: "catalog-entity-detail-items-registrator",
instantiate: () => (ext) => {
const extension = ext as LensRendererExtension;
return extension.catalogEntityDetailItems.map(getRegistratorFor(extension));
},
injectionToken: extensionRegistratorInjectionToken,
});
export default catalogEntityDetailItemsRegistratorInjectable;
const getRegistratorFor = (extension: LensRendererExtension) => ({
apiVersions,
components,
kind,
priority,
}: CatalogEntityDetailRegistration<CatalogEntity>) => getInjectable({
id: `catalog-entity-detail-item-for-${extension.sanitizedExtensionId}-${getRandId({ sep: "-" })}`,
instantiate: () => ({
apiVersions: new Set(apiVersions),
components,
kind,
orderNumber: priority ?? 50,
}),
injectionToken: catalogEntityDetailItemInjectionToken,
});
@@ -0,0 +1,35 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectionToken } from "@ogre-tools/injectable";
import type { CatalogEntity } from "../../../api/catalog-entity";
export interface CatalogEntityDetailsProps<T extends CatalogEntity> {
entity: T;
}
export type CatalogEntityDetailsComponent<T extends CatalogEntity> = React.ComponentType<CatalogEntityDetailsProps<T>>;
export interface CatalogEntityDetailComponents<T extends CatalogEntity> {
Details: CatalogEntityDetailsComponent<T>;
}
export interface CatalogEntityDetailRegistration<T extends CatalogEntity> {
kind: string;
apiVersions: string[];
components: CatalogEntityDetailComponents<T>;
priority?: number;
}
export interface CatalogEntityDetailItem {
kind: string;
apiVersions: Set<string>;
components: CatalogEntityDetailComponents<CatalogEntity>;
orderNumber: number;
}
export const catalogEntityDetailItemInjectionToken = getInjectionToken<CatalogEntityDetailItem>({
id: "catalog-entity-detail-item-token",
});
@@ -0,0 +1,33 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.entityDetails {
/* Move Details panel under TopBar line */
height: calc(100% - var(--main-layout-header));
margin-top: var(--main-layout-header);
}
.metadata {
margin-right: var(--margin);
}
.entityIcon {
margin-right: calc(var(--margin) * 3);
.avatar {
font-size: 3ch;
cursor: pointer;
}
.hint {
text-align: center;
font-size: var(--font-size-small);
text-transform: uppercase;
margin-top: var(--margin);
cursor: default;
user-select: none;
opacity: 0.5;
}
}
@@ -0,0 +1,127 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./view.module.scss";
import React, { Component } from "react";
import { observer } from "mobx-react";
import { Drawer, DrawerItem } from "../../drawer";
import type { CatalogCategory, CatalogEntity } from "../../../../common/catalog";
import { Icon } from "../../icon";
import { CatalogEntityDrawerMenu } from "../catalog-entity-drawer-menu";
import { cssNames } from "../../../utils";
import { Avatar } from "../../avatar";
import type { GetLabelBadges } from "../get-label-badges.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import getLabelBadgesInjectable from "../get-label-badges.injectable";
import isDevelopmentInjectable from "../../../../common/vars/is-development.injectable";
import type { IComputedValue } from "mobx";
import type { CatalogEntityDetailsComponent } from "./token";
import catalogEntityDetailItemsInjectable from "./detail-items.injectable";
export interface CatalogEntityDetailsProps<Entity extends CatalogEntity> {
entity: Entity;
hideDetails(): void;
onRun: () => void;
}
interface Dependencies {
getLabelBadges: GetLabelBadges;
isDevelopment: boolean;
detailItems: IComputedValue<CatalogEntityDetailsComponent<CatalogEntity>[]>;
}
@observer
class NonInjectedCatalogEntityDetails<Entity extends CatalogEntity> extends Component<CatalogEntityDetailsProps<Entity> & Dependencies> {
categoryIcon(category: CatalogCategory) {
if (Icon.isSvg(category.metadata.icon)) {
return <Icon svg={category.metadata.icon} smallest />;
} else {
return <Icon material={category.metadata.icon} smallest />;
}
}
renderContent(entity: Entity) {
const { onRun, hideDetails, getLabelBadges, isDevelopment, detailItems } = this.props;
const details = detailItems.get().map((Details, index) => <Details entity={entity} key={index} />);
return (
<>
<div className="flex" data-testid={`catalog-entity-details-content-for-${entity.getId()}`}>
<div className={styles.entityIcon}>
<Avatar
title={entity.getName()}
colorHash={`${entity.getName()}-${entity.getSource()}`}
size={128}
src={entity.spec.icon?.src}
data-testid="detail-panel-hot-bar-icon"
background={entity.spec.icon?.background}
onClick={onRun}
className={styles.avatar}
>
{entity.spec.icon?.material && <Icon material={entity.spec.icon?.material}/>}
</Avatar>
{entity.isEnabled() && (
<div className={styles.hint}>
Click to open
</div>
)}
</div>
<div className={cssNames("box grow", styles.metadata)}>
<DrawerItem name="Name">
{entity.getName()}
</DrawerItem>
<DrawerItem name="Kind">
{entity.kind}
</DrawerItem>
<DrawerItem name="Source">
{entity.getSource()}
</DrawerItem>
<DrawerItem name="Status">
{entity.status.phase}
</DrawerItem>
<DrawerItem name="Labels">
{getLabelBadges(entity, hideDetails)}
</DrawerItem>
{isDevelopment && (
<DrawerItem name="Id">
{entity.getId()}
</DrawerItem>
)}
</div>
</div>
<div className="box grow">
{details}
</div>
</>
);
}
render() {
const { entity, hideDetails } = this.props;
return (
<Drawer
className={styles.entityDetails}
usePortal={true}
open={true}
title={`${entity.kind}: ${entity.getName()}`}
toolbar={<CatalogEntityDrawerMenu entity={entity} key={entity.getId()} />}
onClose={hideDetails}
data-testid="catalog-entity-details-drawer"
>
{this.renderContent(entity)}
</Drawer>
);
}
}
export const CatalogEntityDetails = withInjectables<Dependencies, CatalogEntityDetailsProps<CatalogEntity>>(NonInjectedCatalogEntityDetails, {
getProps: (di, props) => ({
...props,
getLabelBadges: di.inject(getLabelBadgesInjectable),
isDevelopment: di.inject(isDevelopmentInjectable),
detailItems: di.inject(catalogEntityDetailItemsInjectable, props.entity),
}),
}) as <Entity extends CatalogEntity>(props: CatalogEntityDetailsProps<Entity>) => React.ReactElement;
@@ -0,0 +1,41 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./catalog.module.scss";
import React from "react";
import { getInjectable } from "@ogre-tools/injectable";
import type { CatalogEntity } from "../../api/catalog-entity";
import searchUrlPageParamInjectable from "../input/search-url-page-param.injectable";
import { KubeObject } from "../../../common/k8s-api/kube-object";
import { Badge } from "../badge";
export type GetLabelBadges = (entity: CatalogEntity, onClick?: (evt: React.MouseEvent<any, MouseEvent>) => void) => JSX.Element[];
const getLabelBadgesInjectable = getInjectable({
id: "get-label-badges",
instantiate: (di): GetLabelBadges => {
const searchUrlParam = di.inject(searchUrlPageParamInjectable);
return (entity, onClick) => (
KubeObject.stringifyLabels(entity.metadata.labels)
.map(label => (
<Badge
scrollable
className={styles.badge}
key={label}
label={label}
title={label}
onClick={(event) => {
searchUrlParam.set(label);
onClick?.(event);
event.stopPropagation();
}}
expandable={false}
/>
))
);
},
});
export default getLabelBadgesInjectable;
@@ -0,0 +1,60 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { ReactNode } from "react";
import React, { useState } from "react";
import { MenuItem } from "../menu";
import type { CatalogEntity } from "../../api/catalog-entity";
import { withInjectables } from "@ogre-tools/injectable-react";
import hotbarStoreInjectable from "../../../common/hotbars/store.injectable";
import type { HotbarStore } from "../../../common/hotbars/store";
interface Dependencies {
hotbarStore: HotbarStore;
}
interface HotbarToggleMenuItemProps {
entity: CatalogEntity;
addContent: ReactNode;
removeContent: ReactNode;
}
function NonInjectedHotbarToggleMenuItem({
addContent,
entity,
hotbarStore,
removeContent,
}: Dependencies & HotbarToggleMenuItemProps) {
const [itemInHotbar, setItemInHotbar] = useState(hotbarStore.isAddedToActive(entity));
return (
<MenuItem
onClick={() => {
if (itemInHotbar) {
hotbarStore.removeFromHotbar(entity.getId());
setItemInHotbar(false);
} else {
hotbarStore.addToHotbar(entity);
setItemInHotbar(true);
}
}}
>
{itemInHotbar ? removeContent : addContent }
</MenuItem>
);
}
export const HotbarToggleMenuItem = withInjectables<Dependencies, HotbarToggleMenuItemProps>(
NonInjectedHotbarToggleMenuItem,
{
getProps: (di, props) => ({
hotbarStore: di.inject(hotbarStoreInjectable),
...props,
}),
},
);
@@ -0,0 +1,6 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export * from "./catalog";
@@ -0,0 +1,58 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.ClusterIssues {
min-height: 350px;
position: relative;
grid-area: issues;
padding: calc(var(--margin) * 2) 0;
background: var(--contentColor);
&:global(.OnlyClusterIssues) {
grid-row: row1-start / row2-end;
}
@media (max-width: 1150px) {
grid-area: unset;
&:global(.OnlyClusterIssues) {
grid-row: auto;
}
}
.SubHeader {
padding-top: 0;
padding-bottom: 0;
:global(.Icon) {
font-size: 130%;
color: var(--colorError);
}
}
.message {
white-space: nowrap;
text-overflow: ellipsis;
flex-grow: 3;
}
.object {
white-space: nowrap;
text-overflow: ellipsis;
flex-grow: 2;
}
.noIssues {
.Icon {
color: white;
}
.title {
font-size: large;
color: var(--textColorAccent);
font-weight: bold;
}
}
}
@@ -0,0 +1,202 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./cluster-issues.module.scss";
import React from "react";
import { observer } from "mobx-react";
import type { IComputedValue } from "mobx";
import { computed, makeObservable } from "mobx";
import { Icon } from "../icon";
import { SubHeader } from "../layout/sub-header";
import { Table, TableCell, TableHead, TableRow } from "../table";
import { cssNames, prevDefault } from "../../utils";
import type { ItemObject } from "../../../common/item.store";
import { Spinner } from "../spinner";
import type { ApiManager } from "../../../common/k8s-api/api-manager";
import { KubeObjectAge } from "../kube-object/age";
import { withInjectables } from "@ogre-tools/injectable-react";
import type { NodeStore } from "../+nodes/store";
import type { EventStore } from "../+events/store";
import apiManagerInjectable from "../../../common/k8s-api/api-manager/manager.injectable";
import eventStoreInjectable from "../+events/store.injectable";
import nodeStoreInjectable from "../+nodes/store.injectable";
import type { PageParam } from "../../navigation/page-param";
import type { ToggleKubeDetailsPane } from "../kube-detail-params/toggle-details.injectable";
import kubeSelectedUrlParamInjectable from "../kube-detail-params/kube-selected-url.injectable";
import toggleKubeDetailsPaneInjectable from "../kube-detail-params/toggle-details.injectable";
import type { LensTheme } from "../../themes/lens-theme";
import activeThemeInjectable from "../../themes/active.injectable";
export interface ClusterIssuesProps {
className?: string;
}
interface Warning extends ItemObject {
kind: string;
message: string | undefined;
selfLink: string;
renderAge: () => React.ReactElement;
ageMs: number;
}
enum sortBy {
type = "type",
object = "object",
age = "age",
}
interface Dependencies {
activeTheme: IComputedValue<LensTheme>;
nodeStore: NodeStore;
eventStore: EventStore;
apiManager: ApiManager;
kubeSelectedUrlParam: PageParam<string>;
toggleKubeDetailsPane: ToggleKubeDetailsPane;
}
@observer
class NonInjectedClusterIssues extends React.Component<ClusterIssuesProps & Dependencies> {
constructor(props: ClusterIssuesProps & Dependencies) {
super(props);
makeObservable(this);
}
@computed get warnings(): Warning[] {
return [
...this.props.nodeStore.items.flatMap(node => (
node.getWarningConditions()
.map(({ message }) => ({
selfLink: node.selfLink,
getId: node.getId,
getName: node.getName,
kind: node.kind,
message,
renderAge: () => <KubeObjectAge key="age" object={node} />,
ageMs: -node.getCreationTimestamp(),
}))
)),
...this.props.eventStore.getWarnings().map(warning => ({
getId: () => warning.involvedObject.uid,
getName: () => warning.involvedObject.name,
renderAge: () => <KubeObjectAge key="age" object={warning} />,
ageMs: -warning.getCreationTimestamp(),
message: warning.message,
kind: warning.kind,
selfLink: this.props.apiManager.lookupApiLink(warning.involvedObject, warning),
})),
];
}
getTableRow = (uid: string) => {
const { warnings } = this;
const { kubeSelectedUrlParam, toggleKubeDetailsPane: toggleDetails } = this.props;
const warning = warnings.find(warn => warn.getId() == uid);
if (!warning) {
return undefined;
}
const { getId, getName, message, kind, selfLink, renderAge } = warning;
return (
<TableRow
key={getId()}
sortItem={warning}
selected={selfLink === kubeSelectedUrlParam.get()}
onClick={prevDefault(() => toggleDetails(selfLink))}
>
<TableCell className={styles.message}>
{message ?? "<unknown>"}
</TableCell>
<TableCell className={styles.object}>
{getName()}
</TableCell>
<TableCell className="kind">
{kind}
</TableCell>
<TableCell className="age">
{renderAge()}
</TableCell>
</TableRow>
);
};
renderContent() {
const { warnings } = this;
if (!this.props.eventStore.isLoaded) {
return (
<Spinner center/>
);
}
if (!warnings.length) {
return (
<div className={cssNames(styles.noIssues, "flex column box grow gaps align-center justify-center")}>
<Icon
className={styles.Icon}
material="check"
big
sticker
/>
<p className={styles.title}>No issues found</p>
<p>Everything is fine in the Cluster</p>
</div>
);
}
return (
<>
<SubHeader className={styles.SubHeader}>
<Icon material="error_outline"/>
{` Warnings: ${warnings.length}`}
</SubHeader>
<Table
tableId="cluster_issues"
items={warnings}
virtual
selectable
sortable={{
[sortBy.type]: warning => warning.kind,
[sortBy.object]: warning => warning.getName(),
[sortBy.age]: warning => warning.ageMs,
}}
sortByDefault={{ sortBy: sortBy.object, orderBy: "asc" }}
sortSyncWithUrl={false}
getTableRow={this.getTableRow}
className={cssNames("box grow", this.props.activeTheme.get().type)}
>
<TableHead nowrap>
<TableCell className="message">Message</TableCell>
<TableCell className="object" sortBy={sortBy.object}>Object</TableCell>
<TableCell className="kind" sortBy={sortBy.type}>Type</TableCell>
<TableCell className="timestamp" sortBy={sortBy.age}>Age</TableCell>
</TableHead>
</Table>
</>
);
}
render() {
return (
<div className={cssNames(styles.ClusterIssues, "flex column", this.props.className)}>
{this.renderContent()}
</div>
);
}
}
export const ClusterIssues = withInjectables<Dependencies, ClusterIssuesProps>(NonInjectedClusterIssues, {
getProps: (di, props) => ({
...props,
activeTheme: di.inject(activeThemeInjectable),
apiManager: di.inject(apiManagerInjectable),
eventStore: di.inject(eventStoreInjectable),
nodeStore: di.inject(nodeStoreInjectable),
kubeSelectedUrlParam: di.inject(kubeSelectedUrlParamInjectable),
toggleKubeDetailsPane: di.inject(toggleKubeDetailsPaneInjectable),
}),
});
@@ -0,0 +1,83 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import React from "react";
import { observer } from "mobx-react";
import type { NodeStore } from "../+nodes/store";
import { Radio, RadioGroup } from "../radio";
import type { ClusterOverviewStore } from "./cluster-overview-store/cluster-overview-store";
import { MetricNodeRole, MetricType } from "./cluster-overview-store/cluster-overview-store";
import clusterOverviewStoreInjectable from "./cluster-overview-store/cluster-overview-store.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import nodeStoreInjectable from "../+nodes/store.injectable";
import { normalizeMetrics } from "../../../common/k8s-api/endpoints/metrics.api";
interface Dependencies {
clusterOverviewStore: ClusterOverviewStore;
nodeStore: NodeStore;
}
const NonInjectedClusterMetricSwitchers = observer(({
clusterOverviewStore,
nodeStore,
}: Dependencies) => {
const { masterNodes, workerNodes } = nodeStore;
const { cpuUsage, memoryUsage } = clusterOverviewStore.metrics ?? {};
const hasMasterNodes = masterNodes.length > 0;
const hasWorkerNodes = workerNodes.length > 0;
const hasCpuMetrics = normalizeMetrics(cpuUsage).data.result[0].values.length > 0;
const hasMemoryMetrics = normalizeMetrics(memoryUsage).data.result[0].values.length > 0;
return (
<div className="flex gaps" style={{ marginBottom: "calc(var(--margin) * 2)" }}>
<div className="box grow">
<RadioGroup
asButtons
className="RadioGroup flex gaps"
value={clusterOverviewStore.metricNodeRole}
onChange={metric => clusterOverviewStore.metricNodeRole = metric}
>
<Radio
label="Master"
value={MetricNodeRole.MASTER}
disabled={!hasMasterNodes}
/>
<Radio
label="Worker"
value={MetricNodeRole.WORKER}
disabled={!hasWorkerNodes}
/>
</RadioGroup>
</div>
<div className="box grow" style={{ textAlign: "right" }}>
<RadioGroup
asButtons
className="RadioGroup flex gaps"
value={clusterOverviewStore.metricType}
onChange={value => clusterOverviewStore.metricType = value}
>
<Radio
label="CPU"
value={MetricType.CPU}
disabled={!hasCpuMetrics}
/>
<Radio
label="Memory"
value={MetricType.MEMORY}
disabled={!hasMemoryMetrics}
/>
</RadioGroup>
</div>
</div>
);
});
export const ClusterMetricSwitchers = withInjectables<Dependencies>(NonInjectedClusterMetricSwitchers, {
getProps: (di) => ({
clusterOverviewStore: di.inject(clusterOverviewStoreInjectable),
nodeStore: di.inject(nodeStoreInjectable),
}),
});
@@ -0,0 +1,23 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.ClusterMetrics {
position: relative;
min-height: 280px;
padding: calc(var(--margin) * 2);
background: var(--contentColor);
.chart {
:global(.chart-container) {
width: 100%;
height: 100%;
}
}
.empty {
text-align: center;
padding-bottom: 45px;
}
}
@@ -0,0 +1,129 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./cluster-metrics.module.scss";
import React, { useState } from "react";
import { observer } from "mobx-react";
import type { ChartOptions, ChartPoint } from "chart.js";
import type { ClusterOverviewStore } from "./cluster-overview-store/cluster-overview-store";
import { MetricType } from "./cluster-overview-store/cluster-overview-store";
import { BarChart } from "../chart";
import { bytesToUnits, cssNames } from "../../utils";
import { Spinner } from "../spinner";
import { ZebraStripesPlugin } from "../chart/zebra-stripes.plugin";
import { ClusterNoMetrics } from "./cluster-no-metrics";
import { ClusterMetricSwitchers } from "./cluster-metric-switchers";
import { getMetricLastPoints } from "../../../common/k8s-api/endpoints/metrics.api";
import { withInjectables } from "@ogre-tools/injectable-react";
import clusterOverviewStoreInjectable from "./cluster-overview-store/cluster-overview-store.injectable";
interface Dependencies {
clusterOverviewStore: ClusterOverviewStore;
}
const NonInjectedClusterMetrics = observer(({ clusterOverviewStore: { metricType, metricNodeRole, getMetricsValues, metricsLoaded, metrics }}: Dependencies) => {
const [plugins] = useState([new ZebraStripesPlugin()]);
const { memoryCapacity, cpuCapacity } = getMetricLastPoints(metrics ?? {});
const metricValues = getMetricsValues(metrics ?? {});
const colors = { cpu: "#3D90CE", memory: "#C93DCE" };
const data = metricValues.map(value => ({
x: value[0],
y: parseFloat(value[1]).toFixed(3),
}));
const datasets = [{
id: metricType + metricNodeRole,
label: `${metricType.toUpperCase()} usage`,
borderColor: colors[metricType],
data,
}];
const cpuOptions: ChartOptions = {
scales: {
yAxes: [{
ticks: {
suggestedMax: cpuCapacity,
callback: (value) => value,
},
}],
},
tooltips: {
callbacks: {
label: ({ index }, data) => {
if (!index) {
return "<unknown>";
}
const value = data.datasets?.[0].data?.[index] as ChartPoint;
return value.y?.toString() ?? "<unknown>";
},
},
},
};
const memoryOptions: ChartOptions = {
scales: {
yAxes: [{
ticks: {
suggestedMax: memoryCapacity,
callback: (value: string) => !value ? 0 : bytesToUnits(parseInt(value)),
},
}],
},
tooltips: {
callbacks: {
label: ({ index }, data) => {
if (!index) {
return "<unknown>";
}
const value = data.datasets?.[0].data?.[index] as ChartPoint;
return bytesToUnits(parseInt(value.y as string), { precision: 3 });
},
},
},
};
const options = metricType === MetricType.CPU ? cpuOptions : memoryOptions;
const renderMetrics = () => {
if (!metricValues.length && !metricsLoaded) {
return <Spinner center/>;
}
if (!memoryCapacity || !cpuCapacity) {
return <ClusterNoMetrics className={styles.empty}/>;
}
return (
<BarChart
name={`${metricNodeRole}-${metricType}`}
options={options}
data={{ datasets }}
timeLabelStep={5}
showLegend={false}
plugins={plugins}
className={styles.chart}
/>
);
};
return (
<div className={cssNames(styles.ClusterMetrics, "flex column")}>
<ClusterMetricSwitchers/>
{renderMetrics()}
</div>
);
});
export const ClusterMetrics = withInjectables<Dependencies>(
NonInjectedClusterMetrics,
{
getProps: (di) => ({
clusterOverviewStore: di.inject(clusterOverviewStoreInjectable),
}),
},
);
@@ -0,0 +1,18 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.ClusterNoMetrics {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
line-height: 1.7;
flex-grow: 1;
.link {
color: var(--blue);
cursor: pointer;
}
}
@@ -0,0 +1,55 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./cluster-no-metrics.module.scss";
import React from "react";
import { Icon } from "../icon";
import { cssNames } from "../../utils";
import type { NavigateToEntitySettings } from "../../../common/front-end-routing/routes/entity-settings/navigate-to-entity-settings.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import navigateToEntitySettingsInjectable from "../../../common/front-end-routing/routes/entity-settings/navigate-to-entity-settings.injectable";
import hostedClusterInjectable from "../../cluster-frame-context/hosted-cluster.injectable";
export interface ClusterNoMetricsProps {
className: string;
}
interface Dependencies {
navigateToEntitySettings: NavigateToEntitySettings;
clusterId: string | undefined;
}
export function NonInjectedClusterNoMetrics({ className, navigateToEntitySettings, clusterId }: Dependencies & ClusterNoMetricsProps) {
function openMetricSettingsPage() {
if (clusterId) {
navigateToEntitySettings(clusterId, "metrics");
}
}
return (
<div className={cssNames(styles.ClusterNoMetrics, className)} data-testid="no-metrics-message">
<Icon material="info"/>
<p>Metrics are not available due to missing or invalid Prometheus configuration.</p>
<p><span className={styles.link} onClick={openMetricSettingsPage}>Open cluster settings</span></p>
</div>
);
}
export const ClusterNoMetrics = withInjectables<Dependencies, ClusterNoMetricsProps>(
NonInjectedClusterNoMetrics,
{
getProps: (di, props) => {
const cluster = di.inject(hostedClusterInjectable);
return {
navigateToEntitySettings: di.inject(navigateToEntitySettingsInjectable),
clusterId: cluster?.id,
...props,
};
},
},
);
@@ -0,0 +1,21 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { routeSpecificComponentInjectionToken } from "../../routes/route-specific-component-injection-token";
import { ClusterOverview } from "./cluster-overview";
import clusterOverviewRouteInjectable from "../../../common/front-end-routing/routes/cluster/overview/cluster-overview-route.injectable";
const clusterOverviewRouteComponentInjectable = getInjectable({
id: "cluster-overview-route-component",
instantiate: (di) => ({
route: di.inject(clusterOverviewRouteInjectable),
Component: ClusterOverview,
}),
injectionToken: routeSpecificComponentInjectionToken,
});
export default clusterOverviewRouteComponentInjectable;
@@ -0,0 +1,43 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { Icon } from "../icon";
import React from "react";
import type {
SidebarItemRegistration } from "../layout/sidebar-items.injectable";
import {
sidebarItemsInjectionToken,
} from "../layout/sidebar-items.injectable";
import { computed } from "mobx";
import clusterOverviewRouteInjectable from "../../../common/front-end-routing/routes/cluster/overview/cluster-overview-route.injectable";
import routeIsActiveInjectable from "../../routes/route-is-active.injectable";
import navigateToClusterOverviewInjectable from "../../../common/front-end-routing/routes/cluster/overview/navigate-to-cluster-overview.injectable";
const clusterOverviewSidebarItemsInjectable = getInjectable({
id: "cluster-overview-sidebar-items",
instantiate: (di) => {
const route = di.inject(clusterOverviewRouteInjectable);
const navigateToClusterOverview = di.inject(navigateToClusterOverviewInjectable);
const routeIsActive = di.inject(routeIsActiveInjectable, route);
return computed((): SidebarItemRegistration[] => [
{
id: "cluster-overview",
parentId: null,
title: "Cluster",
getIcon: () => <Icon svg="kube" />,
onClick: navigateToClusterOverview,
isActive: routeIsActive,
isVisible: route.isEnabled,
orderNumber: 10,
},
]);
},
injectionToken: sidebarItemsInjectionToken,
});
export default clusterOverviewSidebarItemsInjectable;
@@ -0,0 +1,44 @@
/**
* 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 { ClusterOverviewStorageState } from "./cluster-overview-store";
import { ClusterOverviewStore, MetricNodeRole, MetricType } from "./cluster-overview-store";
import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable";
import { kubeObjectStoreInjectionToken } from "../../../../common/k8s-api/api-manager/kube-object-store-token";
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";
import requestClusterMetricsByNodeNamesInjectable from "../../../../common/k8s-api/endpoints/metrics.api/request-cluster-metrics-by-node-names.injectable";
import clusterFrameContextForNamespacedResourcesInjectable from "../../../cluster-frame-context/for-namespaced-resources.injectable";
import loggerInjectable from "../../../../common/logger.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);
return new ClusterOverviewStore({
storage: createStorage<ClusterOverviewStorageState>(
"cluster_overview",
{
metricType: MetricType.CPU, // setup defaults
metricNodeRole: MetricNodeRole.WORKER,
},
),
nodeStore: di.inject(nodeStoreInjectable),
requestClusterMetricsByNodeNames: di.inject(requestClusterMetricsByNodeNamesInjectable),
context: di.inject(clusterFrameContextForNamespacedResourcesInjectable),
logger: di.inject(loggerInjectable),
}, clusterApi);
},
injectionToken: kubeObjectStoreInjectionToken,
});
export default clusterOverviewStoreInjectable;
@@ -0,0 +1,118 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { action, observable, reaction, when, makeObservable } from "mobx";
import type { KubeObjectStoreDependencies } from "../../../../common/k8s-api/kube-object.store";
import { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store";
import type { Cluster, ClusterApi } from "../../../../common/k8s-api/endpoints";
import type { StorageLayer } from "../../../utils";
import { autoBind } from "../../../utils";
import type { NodeStore } from "../../+nodes/store";
import type { ClusterMetricData, RequestClusterMetricsByNodeNames } from "../../../../common/k8s-api/endpoints/metrics.api/request-cluster-metrics-by-node-names.injectable";
import type { RequestMetricsParams } from "../../../../common/k8s-api/endpoints/metrics.api/request-metrics.injectable";
import { normalizeMetrics } from "../../../../common/k8s-api/endpoints/metrics.api";
export enum MetricType {
MEMORY = "memory",
CPU = "cpu",
}
export enum MetricNodeRole {
MASTER = "master",
WORKER = "worker",
}
export interface ClusterOverviewStorageState {
metricType: MetricType;
metricNodeRole: MetricNodeRole;
}
interface ClusterOverviewStoreDependencies extends KubeObjectStoreDependencies {
readonly storage: StorageLayer<ClusterOverviewStorageState>;
readonly nodeStore: NodeStore;
requestClusterMetricsByNodeNames: RequestClusterMetricsByNodeNames;
}
export class ClusterOverviewStore extends KubeObjectStore<Cluster, ClusterApi> implements ClusterOverviewStorageState {
@observable metrics: ClusterMetricData | undefined = undefined;
get metricsLoaded() {
return !!this.metrics;
}
get metricType(): MetricType {
return this.dependencies.storage.get().metricType;
}
set metricType(value: MetricType) {
this.dependencies.storage.merge({ metricType: value });
}
get metricNodeRole(): MetricNodeRole {
return this.dependencies.storage.get().metricNodeRole;
}
set metricNodeRole(value: MetricNodeRole) {
this.dependencies.storage.merge({ metricNodeRole: value });
}
constructor(protected readonly dependencies: ClusterOverviewStoreDependencies, api: ClusterApi) {
super(dependencies, api);
makeObservable(this);
autoBind(this);
this.init();
}
private init() {
// TODO: refactor, seems not a correct place to be
// auto-refresh metrics on user-action
reaction(() => this.metricNodeRole, () => {
if (this.metrics) {
this.resetMetrics();
this.loadMetrics();
}
});
// check which node type to select
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;
});
}
@action
async loadMetrics(params?: RequestMetricsParams) {
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 this.dependencies.requestClusterMetricsByNodeNames(nodes.map(node => node.getName()), params);
}
getMetricsValues(source: Partial<ClusterMetricData>): [number, string][] {
switch (this.metricType) {
case MetricType.CPU:
return normalizeMetrics(source.cpuUsage).data.result[0].values;
case MetricType.MEMORY:
return normalizeMetrics(source.memoryUsage).data.result[0].values;
default:
return [];
}
}
@action
resetMetrics() {
this.metrics = undefined;
}
reset() {
super.reset();
this.resetMetrics();
this.dependencies.storage?.reset();
}
}
@@ -0,0 +1,25 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.ClusterOverview {
position: relative;
height: 100%;
min-height: 650px;
display: grid;
grid-gap: calc(var(--margin) * 2);
grid-template-areas:
"barcharts piechars"
"issues issues";
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
grid-template-rows:
[row1-start] 1fr
[row2-start] 1fr;
@media (max-width: 1150px) {
grid-template-columns: minmax(500px, 1fr);
grid-template-rows: 1fr;
grid-template-areas: none;
}
}
@@ -0,0 +1,129 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./cluster-overview.module.scss";
import React from "react";
import { reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
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 { ClusterMetricsResourceType } from "../../../common/cluster-types";
import type { EventStore } from "../+events/store";
import { withInjectables } from "@ogre-tools/injectable-react";
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 "../../cluster-frame-context/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: SubscribeStores;
clusterOverviewStore: ClusterOverviewStore;
hostedCluster: Cluster;
podStore: PodStore;
eventStore: EventStore;
nodeStore: NodeStore;
}
@observer
class NonInjectedClusterOverview extends React.Component<Dependencies> {
private metricPoller = interval(60, () => this.loadMetrics());
loadMetrics() {
if (this.props.hostedCluster.available) {
this.props.clusterOverviewStore.loadMetrics();
}
}
componentDidMount() {
this.metricPoller.start(true);
disposeOnUnmount(this, [
this.props.subscribeStores([
this.props.podStore,
this.props.eventStore,
this.props.nodeStore,
]),
reaction(
() => this.props.clusterOverviewStore.metricNodeRole, // Toggle Master/Worker node switcher
() => this.metricPoller.restart(true),
),
]);
}
componentWillUnmount() {
this.metricPoller.stop();
}
renderMetrics(isMetricsHidden: boolean) {
if (isMetricsHidden) {
return null;
}
return (
<>
<ClusterMetrics/>
<ClusterPieCharts/>
</>
);
}
renderClusterOverview(isLoaded: boolean, isMetricsHidden: boolean) {
if (!isLoaded) {
return <Spinner center/>;
}
return (
<>
{this.renderMetrics(isMetricsHidden)}
<ClusterIssues className={isMetricsHidden ? "OnlyClusterIssues" : ""}/>
</>
);
}
render() {
const { eventStore, nodeStore, hostedCluster } = this.props;
const isLoaded = nodeStore.isLoaded && eventStore.isLoaded;
const isMetricHidden = hostedCluster.isMetricHidden(ClusterMetricsResourceType.Cluster);
return (
<TabLayout scrollable>
<div className={styles.ClusterOverview} data-testid="cluster-overview-page">
{this.renderClusterOverview(isLoaded, isMetricHidden)}
</div>
</TabLayout>
);
}
}
export const ClusterOverview = withInjectables<Dependencies>(NonInjectedClusterOverview, {
getProps: (di) => {
const hostedCluster = di.inject(hostedClusterInjectable);
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),
};
},
});
@@ -0,0 +1,22 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.empty {
background: var(--contentColor);
min-height: 280px;
text-align: center;
padding: calc(var(--padding) * 2);
}
.chart {
--flex-gap: calc(var(--padding) * 2);
background: var(--contentColor);
padding: calc(var(--padding) * 2) var(--padding);
}
.noMetrics {
display: flex;
flex-grow: 1;
}
@@ -0,0 +1,288 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./cluster-pie-charts.module.scss";
import React from "react";
import { observer } from "mobx-react";
import type { ClusterOverviewStore } from "./cluster-overview-store/cluster-overview-store";
import { MetricNodeRole } from "./cluster-overview-store/cluster-overview-store";
import { Spinner } from "../spinner";
import { Icon } from "../icon";
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 type { LensTheme } from "../../themes/lens-theme";
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 type { IComputedValue } from "mobx";
import activeThemeInjectable from "../../themes/active.injectable";
import type { ClusterMetricData } from "../../../common/k8s-api/endpoints/metrics.api/request-cluster-metrics-by-node-names.injectable";
function createLabels(rawLabelData: [string, number | undefined][]): string[] {
return rawLabelData.map(([key, value]) => `${key}: ${value?.toFixed(2) || "N/A"}`);
}
const checkedBytesToUnits = (value: number | undefined) => (
typeof value === "number"
? bytesToUnits(value)
: "N/A"
);
interface Dependencies {
clusterOverviewStore: ClusterOverviewStore;
nodeStore: NodeStore;
activeTheme: IComputedValue<LensTheme>;
}
const NonInjectedClusterPieCharts = observer(({
clusterOverviewStore,
nodeStore,
activeTheme,
}: Dependencies) => {
const renderLimitWarning = () => {
return (
<div className="node-warning flex gaps align-center">
<Icon material="info"/>
<p>Specified limits are higher than node capacity!</p>
</div>
);
};
const renderCharts = (lastPoints: Partial<Record<keyof ClusterMetricData, number>>) => {
const {
memoryUsage, memoryRequests, memoryAllocatableCapacity, memoryCapacity, memoryLimits,
cpuUsage, cpuRequests, cpuAllocatableCapacity, cpuCapacity, cpuLimits,
podUsage, podAllocatableCapacity, podCapacity,
} = lastPoints;
if (
typeof cpuCapacity !== "number" ||
typeof cpuAllocatableCapacity !== "number" ||
typeof podCapacity !== "number" ||
typeof podAllocatableCapacity !== "number" ||
typeof memoryAllocatableCapacity !== "number" ||
typeof memoryCapacity !== "number" ||
typeof memoryUsage !== "number" ||
typeof memoryRequests !== "number"
) {
return null;
}
const defaultColor = activeTheme.get().colors.pieChartDefaultColor;
const cpuData: PieChartData = {
datasets: [
{
data: [
cpuUsage,
cpuUsage ? cpuAllocatableCapacity - cpuUsage : 1,
],
backgroundColor: [
"#c93dce",
defaultColor,
],
id: "cpuUsage",
label: "Usage",
},
{
data: [
cpuRequests,
cpuRequests ? cpuAllocatableCapacity - cpuRequests : 1,
],
backgroundColor: [
"#4caf50",
defaultColor,
],
id: "cpuRequests",
label: "Requests",
},
{
data: [
cpuLimits,
Math.max(0, cpuAllocatableCapacity - (cpuLimits ?? cpuAllocatableCapacity)),
],
backgroundColor: [
"#3d90ce",
defaultColor,
],
id: "cpuLimits",
label: "Limits",
},
],
labels: createLabels([
["Usage", cpuUsage],
["Requests", cpuRequests],
["Limits", cpuLimits],
["Allocatable Capacity", cpuAllocatableCapacity],
["Capacity", cpuCapacity],
]),
};
const memoryData: PieChartData = {
datasets: [
{
data: [
memoryUsage,
memoryUsage ? memoryAllocatableCapacity - memoryUsage : 1,
],
backgroundColor: [
"#c93dce",
defaultColor,
],
id: "memoryUsage",
label: "Usage",
},
{
data: [
memoryRequests,
memoryRequests ? memoryAllocatableCapacity - memoryRequests : 1,
],
backgroundColor: [
"#4caf50",
defaultColor,
],
id: "memoryRequests",
label: "Requests",
},
{
data: [
memoryLimits,
Math.max(0, memoryAllocatableCapacity - (memoryLimits ?? memoryAllocatableCapacity)),
],
backgroundColor: [
"#3d90ce",
defaultColor,
],
id: "memoryLimits",
label: "Limits",
},
],
labels: [
`Usage: ${bytesToUnits(memoryUsage)}`,
`Requests: ${bytesToUnits(memoryRequests)}`,
`Limits: ${checkedBytesToUnits(memoryLimits)}`,
`Allocatable Capacity: ${bytesToUnits(memoryAllocatableCapacity)}`,
`Capacity: ${bytesToUnits(memoryCapacity)}`,
],
};
const podsData: PieChartData = {
datasets: [
{
data: [
podUsage,
podUsage ? podAllocatableCapacity - podUsage : 1,
],
backgroundColor: [
"#4caf50",
defaultColor,
],
id: "podUsage",
label: "Usage",
tooltipLabels: [
(percent) => `Usage: ${percent}`,
(percent) => `Available: ${percent}`,
],
},
],
labels: [
`Usage: ${podUsage || 0}`,
`Capacity: ${podAllocatableCapacity}`,
],
};
return (
<div className="flex justify-center box grow gaps">
<div className={cssNames(styles.chart, "flex column align-center box grow")}>
<PieChart
data={cpuData}
title="CPU"
legendColors={[
"#c93dce",
"#4caf50",
"#3d90ce",
"#032b4d",
defaultColor,
]}
/>
{((cpuLimits ?? cpuAllocatableCapacity) > cpuAllocatableCapacity) && renderLimitWarning()}
</div>
<div className={cssNames(styles.chart, "flex column align-center box grow")}>
<PieChart
data={memoryData}
title="Memory"
legendColors={[
"#c93dce",
"#4caf50",
"#3d90ce",
"#032b4d",
defaultColor,
]}
/>
{((memoryLimits ?? memoryAllocatableCapacity) > memoryAllocatableCapacity) && renderLimitWarning()}
</div>
<div className={cssNames(styles.chart, "flex column align-center box grow")}>
<PieChart
data={podsData}
title="Pods"
legendColors={["#4caf50", defaultColor]}
/>
</div>
</div>
);
};
const renderContent = ({ metricNodeRole, metrics }: ClusterOverviewStore) => {
const { masterNodes, workerNodes } = nodeStore;
const nodes = metricNodeRole === MetricNodeRole.MASTER ? masterNodes : workerNodes;
if (!nodes.length) {
return (
<div className={cssNames(styles.empty, "flex column box grow align-center justify-center")}>
<Icon material="info"/>
No Nodes Available.
</div>
);
}
if (!metrics) {
return (
<div className={cssNames(styles.empty, "flex justify-center align-center box grow")}>
<Spinner/>
</div>
);
}
const lastPoints = getMetricLastPoints(metrics);
const { memoryCapacity, cpuCapacity, podCapacity } = lastPoints;
if (!memoryCapacity || !cpuCapacity || !podCapacity) {
return (
<div className={styles.noMetrics}>
<ClusterNoMetrics className={styles.empty}/>
</div>
);
}
return renderCharts(lastPoints);
};
return (
<div className="flex">
{renderContent(clusterOverviewStore)}
</div>
);
});
export const ClusterPieCharts = withInjectables<Dependencies>(NonInjectedClusterPieCharts, {
getProps: (di) => ({
clusterOverviewStore: di.inject(clusterOverviewStoreInjectable),
nodeStore: di.inject(nodeStoreInjectable),
activeTheme: di.inject(activeThemeInjectable),
}),
});
@@ -0,0 +1,19 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
$hpa-status-colors: (
abletoscale: var(--colorOk),
scalingactive: var(--colorInfo),
scalinglimited: var(--colorSoftError),
);
@mixin hpa-status-bgc {
@each $status, $color in $hpa-status-colors {
&.#{$status} {
background: $color;
color: white;
}
}
}
@@ -0,0 +1,37 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { HpaMetricType } from "../../../common/k8s-api/endpoints";
import type { LabelSelector } from "../../../common/k8s-api/kube-object";
type MetricNames = Partial<Record<"resource" | "pods" | "object" | "external" | "containerResource", {
name?: string;
metricName?: string;
metric?: {
name?: string;
selector?: LabelSelector;
};
}>>;
interface Metric extends MetricNames {
type: HpaMetricType;
}
export function getMetricName(metric: Metric): string | undefined {
switch (metric.type) {
case HpaMetricType.Resource:
return metric.resource?.name;
case HpaMetricType.Pods:
return metric.pods?.metricName || metric.pods?.metric?.name;
case HpaMetricType.Object:
return metric.object?.metricName || metric.object?.metric?.name;
case HpaMetricType.External:
return metric.external?.metricName || metric.external?.metric?.name;
case HpaMetricType.ContainerResource:
return metric.containerResource?.name;
default:
return undefined;
}
}
@@ -0,0 +1,66 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { HorizontalPodAutoscaler, HorizontalPodAutoscalerMetricSpec, HorizontalPodAutoscalerMetricStatus } from "../../../common/k8s-api/endpoints";
import { HpaMetricType } from "../../../common/k8s-api/endpoints";
import { getMetricName } from "./get-hpa-metric-name";
import { HorizontalPodAutoscalerV1MetricParser } from "./hpa-v1-metric-parser";
import { HorizontalPodAutoscalerV2MetricParser } from "./hpa-v2-metric-parser";
type Parser = HorizontalPodAutoscalerV1MetricParser | HorizontalPodAutoscalerV2MetricParser;
const getHorizontalPodAutoscalerMetrics = getInjectable({
id: "get-horizontal-pod-autoscaler-metrics",
instantiate: () => (hpa: HorizontalPodAutoscaler) => {
const hpaV1Parser = new HorizontalPodAutoscalerV1MetricParser();
const hpaV2Parser = new HorizontalPodAutoscalerV2MetricParser();
const metrics = hpa.spec?.metrics ?? [];
const currentMetrics = hpa.status?.currentMetrics ?? [];
const cpuUtilization = hpa.spec?.targetCPUUtilizationPercentage;
if (cpuUtilization) {
const utilizationCurrent = hpa.status?.currentCPUUtilizationPercentage ? `${hpa.status.currentCPUUtilizationPercentage}%` : "unknown";
const utilizationTarget = cpuUtilization ? `${cpuUtilization}%` : "unknown";
return [`${utilizationCurrent} / ${utilizationTarget}`];
}
return metrics.map((metric) => {
const currentMetric = currentMetrics.find(current =>
current.type === metric.type
&& getMetricName(current) === getMetricName(metric),
);
const h2Values = getMetricValues<HorizontalPodAutoscalerV2MetricParser>(hpaV2Parser, currentMetric, metric);
const h1Values = getMetricValues<HorizontalPodAutoscalerV1MetricParser>(hpaV1Parser, currentMetric, metric);
let values = h1Values;
if (h2Values.current || h2Values.target) {
values = h2Values;
}
return `${values.current ?? "unknown"} / ${values.target ?? "unknown"}`;
});
},
});
function getMetricValues<Type extends Parser>(parser: Type, current: HorizontalPodAutoscalerMetricStatus | undefined, target: HorizontalPodAutoscalerMetricSpec) {
switch (target.type) {
case HpaMetricType.Resource:
return parser.getResource({ current: current?.resource, target: target.resource });
case HpaMetricType.Pods:
return parser.getPods({ current: current?.pods, target: target.pods });
case HpaMetricType.Object:
return parser.getObject({ current: current?.object, target: target.object });
case HpaMetricType.External:
return parser.getExternal({ current: current?.external, target: target.external });
case HpaMetricType.ContainerResource:
return parser.getContainerResource({ current: current?.containerResource, target: target.containerResource });
default:
return {};
}
}
export default getHorizontalPodAutoscalerMetrics;
@@ -0,0 +1,38 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { computed } from "mobx";
import horizontalPodAutoscalersRouteInjectable from "../../../common/front-end-routing/routes/cluster/config/horizontal-pod-autoscalers/horizontal-pod-autoscalers-route.injectable";
import { configSidebarItemId } from "../+config/config-sidebar-items.injectable";
import type { SidebarItemRegistration } from "../layout/sidebar-items.injectable";
import { sidebarItemsInjectionToken } from "../layout/sidebar-items.injectable";
import routeIsActiveInjectable from "../../routes/route-is-active.injectable";
import navigateToHorizontalPodAutoscalersInjectable from "../../../common/front-end-routing/routes/cluster/config/horizontal-pod-autoscalers/navigate-to-horizontal-pod-autoscalers.injectable";
const horizontalPodAutoScalersSidebarItemsInjectable = getInjectable({
id: "horizontal-pod-auto-scalers-sidebar-items",
instantiate: (di) => {
const route = di.inject(horizontalPodAutoscalersRouteInjectable);
const navigateToHorizontalPodAutoscalers = di.inject(navigateToHorizontalPodAutoscalersInjectable);
const routeIsActive = di.inject(routeIsActiveInjectable, route);
return computed((): SidebarItemRegistration[] => [
{
id: "horizontal-pod-auto-scalers",
parentId: configSidebarItemId,
title: "HPA",
onClick: navigateToHorizontalPodAutoscalers,
isActive: routeIsActive,
isVisible: route.isEnabled,
orderNumber: 50,
},
]);
},
injectionToken: sidebarItemsInjectionToken,
});
export default horizontalPodAutoScalersSidebarItemsInjectable;
@@ -0,0 +1,21 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { routeSpecificComponentInjectionToken } from "../../routes/route-specific-component-injection-token";
import horizontalPodAutoscalersRouteInjectable from "../../../common/front-end-routing/routes/cluster/config/horizontal-pod-autoscalers/horizontal-pod-autoscalers-route.injectable";
import { HorizontalPodAutoscalers } from "./hpa";
const horizontalPodAutoscalersRouteComponentInjectable = getInjectable({
id: "horizontal-pod-autoscalers-route-component",
instantiate: (di) => ({
route: di.inject(horizontalPodAutoscalersRouteInjectable),
Component: HorizontalPodAutoscalers,
}),
injectionToken: routeSpecificComponentInjectionToken,
});
export default horizontalPodAutoscalersRouteComponentInjectable;
@@ -0,0 +1,32 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
@import "autoscaler.mixins";
.HpaDetails {
.status {
@include hpa-status-bgc;
}
.metrics .Table {
margin: 0 (-$margin * 3);
.TableCell {
word-break: break-word;
&:first-child {
margin-left: $margin * 2;
}
&:last-child {
margin-right: $margin * 2;
}
&.name {
flex-grow: 2;
}
}
}
}
@@ -0,0 +1,182 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./hpa-details.scss";
import React from "react";
import { observer } from "mobx-react";
import { Link } from "react-router-dom";
import { DrawerItem, DrawerTitle } from "../drawer";
import { Badge } from "../badge";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import { cssNames } from "../../utils";
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 type { ApiManager } from "../../../common/k8s-api/api-manager";
import type { 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";
import loggerInjectable from "../../../common/logger.injectable";
import getHorizontalPodAutoscalerMetrics from "./get-hpa-metrics.injectable";
import { getMetricName } from "./get-hpa-metric-name";
export interface HpaDetailsProps extends KubeObjectDetailsProps<HorizontalPodAutoscaler> {
}
interface Dependencies {
apiManager: ApiManager;
logger: Logger;
getDetailsUrl: GetDetailsUrl;
getMetrics: (hpa: HorizontalPodAutoscaler) => string[];
}
@observer
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: HorizontalPodAutoscalerMetricSpec) => {
const metricName = getMetricName(metric);
switch (metric.type) {
case HpaMetricType.ContainerResource:
// fallthrough
case HpaMetricType.Resource: {
const metricSpec = metric.resource ?? metric.containerResource;
return `Resource ${metricSpec.name} on Pods`;
}
case HpaMetricType.Pods:
return `${metricName} on Pods`;
case HpaMetricType.Object: {
return (
<>
{metricName}
{" "}
{this.renderTargetLink(metric.object?.describedObject)}
</>
);
}
case HpaMetricType.External:
return `${metricName} on ${JSON.stringify(metric.external.metricSelector ?? metric.external.metric?.selector)}`;
}
};
return (
<Table>
<TableHead flat>
<TableCell className="name">Name</TableCell>
<TableCell className="metrics">Current / Target</TableCell>
</TableHead>
{
this.props.getMetrics(hpa)
.map((metrics, index) => (
<TableRow key={index}>
<TableCell className="name">{renderName(hpa.getMetrics()[index])}</TableCell>
<TableCell className="metrics">{metrics}</TableCell>
</TableRow>
))
}
</Table>
);
}
render() {
const { object: hpa, apiManager, getDetailsUrl, logger } = this.props;
if (!hpa) {
return null;
}
if (!(hpa instanceof HorizontalPodAutoscaler)) {
logger.error("[HpaDetails]: passed object that is not an instanceof HorizontalPodAutoscaler", hpa);
return null;
}
const { scaleTargetRef } = hpa.spec;
return (
<div className="HpaDetails">
<DrawerItem name="Reference">
{scaleTargetRef && (
<Link to={getDetailsUrl(apiManager.lookupApiLink(scaleTargetRef, hpa))}>
{scaleTargetRef.kind}
/
{scaleTargetRef.name}
</Link>
)}
</DrawerItem>
<DrawerItem name="Min Pods">
{hpa.getMinPods()}
</DrawerItem>
<DrawerItem name="Max Pods">
{hpa.getMaxPods()}
</DrawerItem>
<DrawerItem name="Replicas">
{hpa.getReplicas()}
</DrawerItem>
<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>
<div className="metrics">
{this.renderMetrics()}
</div>
</div>
);
}
}
export const HpaDetails = withInjectables<Dependencies, HpaDetailsProps>(NonInjectedHpaDetails, {
getProps: (di, props) => ({
...props,
apiManager: di.inject(apiManagerInjectable),
getDetailsUrl: di.inject(getDetailsUrlInjectable),
logger: di.inject(loggerInjectable),
getMetrics: di.inject(getHorizontalPodAutoscalerMetrics),
}),
});
@@ -0,0 +1,70 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { MetricCurrentTarget, V2Beta1ContainerResourceMetricSource, V2Beta1ContainerResourceMetricStatus, V2Beta1ExternalMetricSource, V2Beta1ExternalMetricStatus, V2Beta1ObjectMetricSource, V2Beta1ObjectMetricStatus, V2Beta1PodsMetricSource, V2Beta1PodsMetricStatus, V2Beta1ResourceMetricSource, V2Beta1ResourceMetricStatus } from "../../../common/k8s-api/endpoints";
export class HorizontalPodAutoscalerV1MetricParser {
public getResource({ current, target }: { current: V2Beta1ResourceMetricStatus | undefined; target: V2Beta1ResourceMetricSource }): MetricCurrentTarget {
return {
current: (
typeof current?.currentAverageUtilization === "number"
? `${current.currentAverageUtilization}%`
: current?.currentAverageValue
),
target: (
typeof target?.targetAverageUtilization === "number"
? `${target.targetAverageUtilization}%`
: target?.targetAverageValue
),
};
}
public getPods({ current, target }: { current: V2Beta1PodsMetricStatus | undefined; target: V2Beta1PodsMetricSource }): MetricCurrentTarget {
return {
current: current?.currentAverageValue,
target: target?.targetAverageValue,
};
}
public getObject({ current, target }: { current: V2Beta1ObjectMetricStatus | undefined; target: V2Beta1ObjectMetricSource }): MetricCurrentTarget {
return {
current: (
current?.currentValue
?? current?.averageValue
),
target: (
target?.targetValue
?? target?.averageValue
),
};
}
public getExternal({ current, target }: { current: V2Beta1ExternalMetricStatus | undefined; target: V2Beta1ExternalMetricSource }): MetricCurrentTarget {
return {
current: (
current?.currentValue
?? current?.currentAverageValue
),
target: (
target?.targetValue
?? target?.targetAverageValue
),
};
}
public getContainerResource({ current, target }: { current: V2Beta1ContainerResourceMetricStatus | undefined; target: V2Beta1ContainerResourceMetricSource }): MetricCurrentTarget {
return {
current: (
typeof current?.currentAverageUtilization === "number"
? `${current.currentAverageUtilization}%`
: current?.currentAverageValue
),
target: (
typeof target?.targetAverageUtilization === "number"
? `${target.targetAverageUtilization}%`
: target?.targetAverageValue
),
};
}
}
@@ -0,0 +1,69 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { MetricCurrentTarget, V2ContainerResourceMetricSource, V2ContainerResourceMetricStatus, V2ExternalMetricSource, V2ExternalMetricStatus, V2ObjectMetricSource, V2ObjectMetricStatus, V2PodsMetricSource, V2PodsMetricStatus, V2ResourceMetricSource, V2ResourceMetricStatus } from "../../../common/k8s-api/endpoints";
export class HorizontalPodAutoscalerV2MetricParser {
public getResource({ current, target }: { current: V2ResourceMetricStatus | undefined; target: V2ResourceMetricSource }): MetricCurrentTarget {
return {
current: (
typeof current?.current?.averageUtilization === "number"
? `${current.current?.averageUtilization}%`
: current?.current?.averageValue
),
target: typeof target?.target?.averageUtilization === "number"
? `${target.target.averageUtilization}%`
: target?.target?.averageValue,
};
}
public getPods({ current, target }: { current: V2PodsMetricStatus | undefined; target: V2PodsMetricSource }): MetricCurrentTarget {
return {
current: current?.current?.averageValue,
target: target?.target?.averageValue,
};
}
public getObject({ current, target }: { current: V2ObjectMetricStatus | undefined; target: V2ObjectMetricSource }): MetricCurrentTarget {
return {
current: (
current?.current?.value
?? current?.current?.averageValue
),
target: (
target?.target?.value
?? target?.target?.averageValue
),
};
}
public getExternal({ current, target }: { current: V2ExternalMetricStatus | undefined; target: V2ExternalMetricSource }): MetricCurrentTarget {
const currentAverage = current?.current?.averageValue ? `${current?.current?.averageValue} (avg)` : undefined;
const targetAverage = target?.target?.averageValue ? `${target?.target?.averageValue} (avg)` : undefined;
return {
current: (
current?.current?.value
?? currentAverage
),
target: (
target?.target?.value
?? targetAverage
),
};
}
public getContainerResource({ current, target }: { current: V2ContainerResourceMetricStatus | undefined; target: V2ContainerResourceMetricSource }): MetricCurrentTarget {
return {
current: (
current?.current?.averageValue
?? current?.current?.averageUtilization ? `${current?.current?.averageUtilization}%` : undefined
),
target: (
target?.target?.averageValue
?? target?.target?.averageUtilization ? `${target?.target?.averageUtilization}%` : undefined
),
};
}
}
@@ -0,0 +1,32 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
@import "autoscaler.mixins";
.HorizontalPodAutoscalers {
.TableCell {
&.name {
flex: 1.5;
}
&.warning {
@include table-cell-warning;
}
&.metrics {
flex: 1.5;
}
&.status {
flex: 1.5;
@include table-cell-labels-offsets;
@include hpa-status-bgc;
}
&.age {
flex: .5;
}
}
}
@@ -0,0 +1,127 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./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/horizontal-pod-autoscaler.api";
import { Badge } from "../badge";
import { cssNames } from "../../utils";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import { SiblingsInTabLayout } from "../layout/siblings-in-tab-layout";
import { KubeObjectAge } from "../kube-object/age";
import type { HorizontalPodAutoscalerStore } from "./store";
import { withInjectables } from "@ogre-tools/injectable-react";
import horizontalPodAutoscalerStoreInjectable from "./store.injectable";
import getHorizontalPodAutoscalerMetrics from "./get-hpa-metrics.injectable";
import { NamespaceSelectBadge } from "../+namespaces/namespace-select-badge";
enum columnId {
name = "name",
namespace = "namespace",
metrics = "metrics",
minPods = "min-pods",
maxPods = "max-pods",
replicas = "replicas",
age = "age",
status = "status",
}
interface Dependencies {
horizontalPodAutoscalerStore: HorizontalPodAutoscalerStore;
getMetrics: (hpa: HorizontalPodAutoscaler) => string[];
}
@observer
class NonInjectedHorizontalPodAutoscalers extends React.Component<Dependencies> {
getTargets(hpa: HorizontalPodAutoscaler) {
const metrics = hpa.getMetrics();
if (metrics.length === 0 && !hpa.spec?.targetCPUUtilizationPercentage) {
return <p>--</p>;
}
const metricsRemain = metrics.length > 1 ? `+${metrics.length - 1} more...` : "";
return (
<p>
{this.props.getMetrics(hpa)[0]}
{" "}
{metricsRemain}
</p>
);
}
render() {
return (
<SiblingsInTabLayout>
<KubeObjectListLayout
isConfigurable
tableId="configuration_hpa"
className="HorizontalPodAutoscalers"
store={this.props.horizontalPodAutoscalerStore}
sortingCallbacks={{
[columnId.name]: hpa => hpa.getName(),
[columnId.namespace]: hpa => hpa.getNs(),
[columnId.minPods]: hpa => hpa.getMinPods(),
[columnId.maxPods]: hpa => hpa.getMaxPods(),
[columnId.replicas]: hpa => hpa.getReplicas(),
[columnId.age]: hpa => -hpa.getCreationTimestamp(),
}}
searchFilters={[
hpa => hpa.getSearchFields(),
]}
renderHeaderTitle="Horizontal Pod Autoscalers"
renderTableHeader={[
{ title: "Name", className: "name", sortBy: columnId.name },
{ className: "warning", showWithColumn: columnId.name },
{ title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
{ title: "Metrics", className: "metrics", id: columnId.metrics },
{ title: "Min Pods", className: "min-pods", sortBy: columnId.minPods, id: columnId.minPods },
{ title: "Max Pods", className: "max-pods", sortBy: columnId.maxPods, id: columnId.maxPods },
{ title: "Replicas", className: "replicas", sortBy: columnId.replicas, id: columnId.replicas },
{ title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
{ title: "Status", className: "status scrollable", id: columnId.status },
]}
renderTableContents={hpa => [
hpa.getName(),
<KubeObjectStatusIcon key="icon" object={hpa} />,
<NamespaceSelectBadge
key="namespace"
namespace={hpa.getNs()}
/>,
this.getTargets(hpa),
hpa.getMinPods(),
hpa.getMaxPods(),
hpa.getReplicas(),
<KubeObjectAge key="age" object={hpa} />,
hpa.getConditions()
.filter(({ isReady }) => isReady)
.map(({ type, tooltip }) => (
<Badge
key={type}
label={type}
tooltip={tooltip}
className={cssNames(type.toLowerCase())}
expandable={false}
scrollable={true}
/>
)),
]}
/>
</SiblingsInTabLayout>
);
}
}
export const HorizontalPodAutoscalers = withInjectables<Dependencies>(NonInjectedHorizontalPodAutoscalers, {
getProps: (di, props) => ({
...props,
horizontalPodAutoscalerStore: di.inject(horizontalPodAutoscalerStoreInjectable),
getMetrics: di.inject(getHorizontalPodAutoscalerMetrics),
}),
});
@@ -0,0 +1,7 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export * from "./hpa";
export * from "./hpa-details";
@@ -0,0 +1,29 @@
/**
* 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/kube-object-store-token";
import horizontalPodAutoscalerApiInjectable from "../../../common/k8s-api/endpoints/horizontal-pod-autoscaler.api.injectable";
import loggerInjectable from "../../../common/logger.injectable";
import clusterFrameContextForNamespacedResourcesInjectable from "../../cluster-frame-context/for-namespaced-resources.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({
context: di.inject(clusterFrameContextForNamespacedResourcesInjectable),
logger: di.inject(loggerInjectable),
}, 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,7 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export * from "./leases";
export * from "./lease-details";
@@ -0,0 +1,6 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.LeaseDetails {}
@@ -0,0 +1,48 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./lease-details.scss";
import React from "react";
import { observer } from "mobx-react";
import { DrawerItem } from "../drawer";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import type { Lease } from "../../../common/k8s-api/endpoints";
export interface LeaseDetailsProps extends KubeObjectDetailsProps<Lease> {
}
@observer
export class LeaseDetails extends React.Component<LeaseDetailsProps> {
render() {
const { object: lease } = this.props;
return (
<div className="LeaseDetails">
<DrawerItem name="Holder Identity">
{lease.getHolderIdentity()}
</DrawerItem>
<DrawerItem name="Lease Duration Seconds">
{lease.getLeaseDurationSeconds()}
</DrawerItem>
<DrawerItem name="Lease Transitions" hidden={lease.getLeaseTransitions() === undefined}>
{lease.getLeaseTransitions()}
</DrawerItem>
<DrawerItem name="Acquire Time" hidden={lease.getAcquireTime() === ""}>
{lease.getAcquireTime()}
</DrawerItem>
<DrawerItem name="Renew Time">
{lease.getRenewTime()}
</DrawerItem>
</div >
);
}
}
@@ -0,0 +1,21 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { Leases } from "./leases";
import leasesRouteInjectable from "../../../common/front-end-routing/routes/cluster/config/leases/leases-route.injectable";
import { routeSpecificComponentInjectionToken } from "../../routes/route-specific-component-injection-token";
const leasesRouteComponentInjectable = getInjectable({
id: "leases-route-component",
instantiate: (di) => ({
route: di.inject(leasesRouteInjectable),
Component: Leases,
}),
injectionToken: routeSpecificComponentInjectionToken,
});
export default leasesRouteComponentInjectable;
@@ -0,0 +1,38 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { computed } from "mobx";
import leasesRouteInjectable from "../../../common/front-end-routing/routes/cluster/config/leases/leases-route.injectable";
import { configSidebarItemId } from "../+config/config-sidebar-items.injectable";
import { sidebarItemsInjectionToken } from "../layout/sidebar-items.injectable";
import routeIsActiveInjectable from "../../routes/route-is-active.injectable";
import navigateToLeasesInjectable from "../../../common/front-end-routing/routes/cluster/config/leases/navigate-to-leases.injectable";
const leasesSidebarItemsInjectable = getInjectable({
id: "leases-sidebar-items",
instantiate: (di) => {
const route = di.inject(leasesRouteInjectable);
const navigateToLeases = di.inject(navigateToLeasesInjectable);
const routeIsActive = di.inject(routeIsActiveInjectable, route);
return computed(() => [
{
id: "leases",
parentId: configSidebarItemId,
title: "Leases",
onClick: navigateToLeases,
isActive: routeIsActive,
isVisible: route.isEnabled,
orderNumber: 80,
},
]);
},
injectionToken: sidebarItemsInjectionToken,
});
export default leasesSidebarItemsInjectable;
@@ -0,0 +1,26 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.Leases {
.TableCell {
&.name {
flex: 2;
}
&.warning {
@include table-cell-warning;
}
&.keys {
flex: 2.5;
}
&.age {
flex: .5;
}
}
}
@@ -0,0 +1,85 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./leases.scss";
import * as React from "react";
import { observer } from "mobx-react";
import type { Lease } from "../../../common/k8s-api/endpoints/lease.api";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import { KubeObjectListLayout } from "../kube-object-list-layout";
import { SiblingsInTabLayout } from "../layout/siblings-in-tab-layout";
import { KubeObjectAge } from "../kube-object/age";
import { withInjectables } from "@ogre-tools/injectable-react";
import leaseStoreInjectable from "./store.injectable";
import type { LeaseStore } from "./store";
import { NamespaceSelectBadge } from "../+namespaces/namespace-select-badge";
enum columnId {
name = "name",
namespace = "namespace",
holder = "holder",
age = "age",
}
export interface LeaseProps extends KubeObjectDetailsProps<Lease> {
}
interface Dependencies {
leaseStore: LeaseStore;
}
@observer
class NonInjectedLease extends React.Component<LeaseProps & Dependencies> {
render() {
const { leaseStore } = this.props;
return (
<SiblingsInTabLayout>
<KubeObjectListLayout
isConfigurable
tableId="configuration_leases"
className="Leases"
store={leaseStore}
sortingCallbacks={{
[columnId.name]: lease => lease.getName(),
[columnId.namespace]: lease => lease.getNs(),
[columnId.holder]: lease => lease.getHolderIdentity(),
[columnId.age]: lease => -lease.getCreationTimestamp(),
}}
searchFilters={[
lease => lease.getSearchFields(),
]}
renderHeaderTitle="Leases"
renderTableHeader={[
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ className: "warning", showWithColumn: columnId.name },
{ title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
{ title: "Holder", className: "holder", sortBy: columnId.holder, id: columnId.holder },
{ title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
]}
renderTableContents={lease => [
lease.getName(),
<KubeObjectStatusIcon key="icon" object={lease} />,
<NamespaceSelectBadge
key="namespace"
namespace={lease.getNs()}
/>,
lease.getHolderIdentity(),
<KubeObjectAge key="age" object={lease} />,
]}
/>
</SiblingsInTabLayout>
);
}
}
export const Leases = withInjectables<Dependencies, LeaseProps>(NonInjectedLease, {
getProps: (di, props) => ({
...props,
leaseStore: di.inject(leaseStoreInjectable),
}),
});
@@ -0,0 +1,29 @@
/**
* 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/kube-object-store-token";
import leaseApiInjectable from "../../../common/k8s-api/endpoints/lease.api.injectable";
import loggerInjectable from "../../../common/logger.injectable";
import clusterFrameContextForNamespacedResourcesInjectable from "../../cluster-frame-context/for-namespaced-resources.injectable";
import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable";
import { LeaseStore } from "./store";
const leaseStoreInjectable = getInjectable({
id: "lease-store",
instantiate: (di) => {
assert(di.inject(storesAndApisCanBeCreatedInjectable), "leaseStore is only available in certain environments");
const api = di.inject(leaseApiInjectable);
return new LeaseStore({
context: di.inject(clusterFrameContextForNamespacedResourcesInjectable),
logger: di.inject(loggerInjectable),
}, api);
},
injectionToken: kubeObjectStoreInjectionToken,
});
export default leaseStoreInjectable;
@@ -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 { Lease, LeaseApi } from "../../../common/k8s-api/endpoints/lease.api";
export class LeaseStore extends KubeObjectStore<Lease, LeaseApi> {
}
@@ -0,0 +1,7 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export * from "./limit-ranges";
export * from "./limit-range-details";
@@ -0,0 +1,17 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.LimitRangeDetails {
.DrawerItem {
> .name {
font-weight: $font-weight-normal;
padding-left: 4px;
}
.DrawerItem {
padding-top: 4px;
}
}
}
@@ -0,0 +1,112 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./limit-range-details.scss";
import React from "react";
import { observer } from "mobx-react";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import type { LimitRangeItem } from "../../../common/k8s-api/endpoints/limit-range.api";
import { LimitPart, LimitRange, Resource } from "../../../common/k8s-api/endpoints/limit-range.api";
import { DrawerItem } from "../drawer/drawer-item";
import { Badge } from "../badge";
import type { Logger } from "../../../common/logger";
import { withInjectables } from "@ogre-tools/injectable-react";
import loggerInjectable from "../../../common/logger.injectable";
export interface LimitRangeDetailsProps extends KubeObjectDetailsProps<LimitRange> {
}
function renderLimit(limit: LimitRangeItem, part: LimitPart, resource: Resource) {
const resourceLimit = limit[part]?.[resource];
if (!resourceLimit) {
return null;
}
return <Badge label={`${part}:${resourceLimit}`}/>;
}
function renderResourceLimits(limit: LimitRangeItem, resource: Resource) {
return (
<React.Fragment key={limit.type + resource}>
{renderLimit(limit, LimitPart.MIN, resource)}
{renderLimit(limit, LimitPart.MAX, resource)}
{renderLimit(limit, LimitPart.DEFAULT, resource)}
{renderLimit(limit, LimitPart.DEFAULT_REQUEST, resource)}
{renderLimit(limit, LimitPart.MAX_LIMIT_REQUEST_RATIO, resource)}
</React.Fragment>
);
}
function renderLimitDetails(limits: LimitRangeItem[], resources: Resource[]) {
return resources.map(resource => (
<DrawerItem key={resource} name={resource}>
{
limits.map(limit => renderResourceLimits(limit, resource))
}
</DrawerItem>
));
}
interface Dependencies {
logger: Logger;
}
@observer
class NonInjectedLimitRangeDetails extends React.Component<LimitRangeDetailsProps & Dependencies> {
render() {
const { object: limitRange, logger } = this.props;
if (!limitRange) {
return null;
}
if (!(limitRange instanceof LimitRange)) {
logger.error("[LimitRangeDetails]: passed object that is not an instanceof LimitRange", limitRange);
return null;
}
const containerLimits = limitRange.getContainerLimits();
const podLimits = limitRange.getPodLimits();
const pvcLimits = limitRange.getPVCLimits();
return (
<div className="LimitRangeDetails">
{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>
);
}
}
export const LimitRangeDetails = withInjectables<Dependencies, LimitRangeDetailsProps>(NonInjectedLimitRangeDetails, {
getProps: (di, props) => ({
...props,
logger: di.inject(loggerInjectable),
}),
});
@@ -0,0 +1,21 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { LimitRanges } from "./limit-ranges";
import limitRangesRouteInjectable from "../../../common/front-end-routing/routes/cluster/config/limit-ranges/limit-ranges-route.injectable";
import { routeSpecificComponentInjectionToken } from "../../routes/route-specific-component-injection-token";
const limitRangesRouteComponentInjectable = getInjectable({
id: "limit-ranges-route-component",
instantiate: (di) => ({
route: di.inject(limitRangesRouteInjectable),
Component: LimitRanges,
}),
injectionToken: routeSpecificComponentInjectionToken,
});
export default limitRangesRouteComponentInjectable;
@@ -0,0 +1,38 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { computed } from "mobx";
import limitRangesRouteInjectable from "../../../common/front-end-routing/routes/cluster/config/limit-ranges/limit-ranges-route.injectable";
import { configSidebarItemId } from "../+config/config-sidebar-items.injectable";
import { sidebarItemsInjectionToken } from "../layout/sidebar-items.injectable";
import routeIsActiveInjectable from "../../routes/route-is-active.injectable";
import navigateToLimitRangesInjectable from "../../../common/front-end-routing/routes/cluster/config/limit-ranges/navigate-to-limit-ranges.injectable";
const limitRangesSidebarItemsInjectable = getInjectable({
id: "limit-ranges-sidebar-items",
instantiate: (di) => {
const route = di.inject(limitRangesRouteInjectable);
const navigateToLimitRanges = di.inject(navigateToLimitRangesInjectable);
const routeIsActive = di.inject(routeIsActiveInjectable, route);
return computed(() => [
{
id: "limit-ranges",
parentId: configSidebarItemId,
title: "Limit Ranges",
onClick: navigateToLimitRanges,
isActive: routeIsActive,
isVisible: route.isEnabled,
orderNumber: 40,
},
]);
},
injectionToken: sidebarItemsInjectionToken,
});
export default limitRangesSidebarItemsInjectable;
@@ -0,0 +1,16 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.LimitRanges {
.TableCell {
&.warning {
@include table-cell-warning;
}
}
a.filterNamespace {
border-bottom: unset;
}
}
@@ -0,0 +1,75 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./limit-ranges.scss";
import { observer } from "mobx-react";
import { KubeObjectListLayout } from "../kube-object-list-layout";
import React from "react";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import { SiblingsInTabLayout } from "../layout/siblings-in-tab-layout";
import { KubeObjectAge } from "../kube-object/age";
import type { LimitRangeStore } from "./store";
import { withInjectables } from "@ogre-tools/injectable-react";
import limitRangeStoreInjectable from "./store.injectable";
import { NamespaceSelectBadge } from "../+namespaces/namespace-select-badge";
enum columnId {
name = "name",
namespace = "namespace",
age = "age",
}
interface Dependencies {
limitRangeStore: LimitRangeStore;
}
@observer
class NonInjectedLimitRanges extends React.Component<Dependencies> {
render() {
return (
<SiblingsInTabLayout>
<KubeObjectListLayout
isConfigurable
tableId="configuration_limitranges"
className="LimitRanges"
store={this.props.limitRangeStore}
sortingCallbacks={{
[columnId.name]: limitRange => limitRange.getName(),
[columnId.namespace]: limitRange => limitRange.getNs(),
[columnId.age]: limitRange => -limitRange.getCreationTimestamp(),
}}
searchFilters={[
item => item.getName(),
item => item.getNs(),
]}
renderHeaderTitle="Limit Ranges"
renderTableHeader={[
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ className: "warning", showWithColumn: columnId.name },
{ title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
{ title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
]}
renderTableContents={limitRange => [
limitRange.getName(),
<KubeObjectStatusIcon key="icon" object={limitRange}/>,
<NamespaceSelectBadge
key="namespace"
namespace={limitRange.getNs()}
/>,
<KubeObjectAge key="age" object={limitRange} />,
]}
/>
</SiblingsInTabLayout>
);
}
}
export const LimitRanges = withInjectables<Dependencies>(NonInjectedLimitRanges, {
getProps: (di, props) => ({
...props,
limitRangeStore: di.inject(limitRangeStoreInjectable),
}),
});
@@ -0,0 +1,29 @@
/**
* 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/kube-object-store-token";
import limitRangeApiInjectable from "../../../common/k8s-api/endpoints/limit-range.api.injectable";
import loggerInjectable from "../../../common/logger.injectable";
import clusterFrameContextForNamespacedResourcesInjectable from "../../cluster-frame-context/for-namespaced-resources.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({
context: di.inject(clusterFrameContextForNamespacedResourcesInjectable),
logger: di.inject(loggerInjectable),
}, 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> {
}
@@ -0,0 +1,20 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.ConfigMapDetails {
.data {
margin-bottom: $margin * 2;
.name {
color: var(--textColorSecondary);
font-weight: $font-weight-bold;
padding-bottom: $padding * 0.5;
}
}
.save-btn {
margin-top: $margin;
}
}
@@ -0,0 +1,143 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./config-map-details.scss";
import React from "react";
import { autorun, makeObservable, observable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { DrawerTitle } from "../drawer";
import type { ShowNotification } from "../notifications";
import { Button } from "../button";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import { ConfigMap } from "../../../common/k8s-api/endpoints";
import type { Logger } from "../../../common/logger";
import type { ConfigMapStore } from "./store";
import { withInjectables } from "@ogre-tools/injectable-react";
import configMapStoreInjectable from "./store.injectable";
import showSuccessNotificationInjectable from "../notifications/show-success-notification.injectable";
import showErrorNotificationInjectable from "../notifications/show-error-notification.injectable";
import loggerInjectable from "../../../common/logger.injectable";
import { MonacoEditor } from "../monaco-editor";
export interface ConfigMapDetailsProps extends KubeObjectDetailsProps<ConfigMap> {
}
interface Dependencies {
configMapStore: ConfigMapStore;
logger: Logger;
showSuccessNotification: ShowNotification;
showErrorNotification: ShowNotification;
}
@observer
class NonInjectedConfigMapDetails extends React.Component<ConfigMapDetailsProps & Dependencies> {
@observable isSaving = false;
@observable data = observable.map<string, string | undefined>();
constructor(props: ConfigMapDetailsProps & Dependencies) {
super(props);
makeObservable(this);
}
async componentDidMount() {
disposeOnUnmount(this, [
autorun(() => {
const { object: configMap } = this.props;
if (configMap) {
this.data.replace(configMap.data); // refresh
}
}),
]);
}
save = async () => {
const { object: configMap, configMapStore } = this.props;
try {
this.isSaving = true;
await configMapStore.update(configMap, {
...configMap,
data: Object.fromEntries(this.data),
});
this.props.showSuccessNotification((
<p>
{"ConfigMap "}
<b>{configMap.getName()}</b>
{" successfully updated."}
</p>
));
} catch (error) {
this.props.showErrorNotification(`Failed to save config map: ${error}`);
} finally {
this.isSaving = false;
}
};
render() {
const { object: configMap, logger } = this.props;
if (!configMap) {
return null;
}
if (!(configMap instanceof ConfigMap)) {
logger.error("[ConfigMapDetails]: passed object that is not an instanceof ConfigMap", configMap);
return null;
}
const data = Array.from(this.data.entries());
return (
<div className="ConfigMapDetails">
{
data.length > 0 && (
<>
<DrawerTitle>Data</DrawerTitle>
{
data.map(([name, value = ""]) => (
<div key={name} className="data">
<div className="name">{name}</div>
<MonacoEditor
id={`config-map-data-${name}`}
style={{
resize: "vertical",
overflow: "hidden",
border: "1px solid var(--borderFaintColor)",
borderRadius: "4px",
}}
value={value}
onChange={v => this.data.set(name, v)}
setInitialHeight
/>
</div>
))
}
<Button
primary
label="Save"
waiting={this.isSaving}
className="save-btn"
onClick={this.save}
/>
</>
)
}
</div>
);
}
}
export const ConfigMapDetails = withInjectables<Dependencies, ConfigMapDetailsProps>(NonInjectedConfigMapDetails, {
getProps: (di, props) => ({
...props,
configMapStore: di.inject(configMapStoreInjectable),
showSuccessNotification: di.inject(showSuccessNotificationInjectable),
showErrorNotification: di.inject(showErrorNotificationInjectable),
logger: di.inject(loggerInjectable),
}),
});
@@ -0,0 +1,21 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { ConfigMaps } from "./config-maps";
import configMapsRouteInjectable from "../../../common/front-end-routing/routes/cluster/config/config-maps/config-maps-route.injectable";
import { routeSpecificComponentInjectionToken } from "../../routes/route-specific-component-injection-token";
const configMapsRouteComponentInjectable = getInjectable({
id: "config-maps-route-component",
instantiate: (di) => ({
route: di.inject(configMapsRouteInjectable),
Component: ConfigMaps,
}),
injectionToken: routeSpecificComponentInjectionToken,
});
export default configMapsRouteComponentInjectable;
@@ -0,0 +1,38 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { computed } from "mobx";
import configMapsRouteInjectable from "../../../common/front-end-routing/routes/cluster/config/config-maps/config-maps-route.injectable";
import { configSidebarItemId } from "../+config/config-sidebar-items.injectable";
import { sidebarItemsInjectionToken } from "../layout/sidebar-items.injectable";
import routeIsActiveInjectable from "../../routes/route-is-active.injectable";
import navigateToConfigMapsInjectable from "../../../common/front-end-routing/routes/cluster/config/config-maps/navigate-to-config-maps.injectable";
const configMapsSidebarItemsInjectable = getInjectable({
id: "config-maps-sidebar-items",
instantiate: (di) => {
const route = di.inject(configMapsRouteInjectable);
const navigateToConfigMaps = di.inject(navigateToConfigMapsInjectable);
const routeIsActive = di.inject(routeIsActiveInjectable, route);
return computed(() => [
{
id: "config-maps",
parentId: configSidebarItemId,
title: "ConfigMaps",
onClick: navigateToConfigMaps,
isActive: routeIsActive,
isVisible: route.isEnabled,
orderNumber: 10,
},
]);
},
injectionToken: sidebarItemsInjectionToken,
});
export default configMapsSidebarItemsInjectable;
Loaded 100 of 3321 files, more files were not shown because too many files have changed in this diff. Show more