mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Restructure to monorepo (#6907)
* wip: restructure to monorepo Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * refactor create-release-pr to a package Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * build fixes Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * github workflow fixes Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix typo Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * add webpack-env types to core Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix github workflows Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * refactor/fix integration tests Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * lint fix Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * yarn run dev Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * eslint settings for vscode Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * move templates to right package Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * open-lens build fixes Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * integration test fix Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix nx task dependencies Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * use bash shell for unit tests in test workflow Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix test:unit for windows Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix win-ca webpack error in open-lens Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix win-ca webpack error in open-lens Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix build:app on windows Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * remove ELECTRON_BUILDER_EXTRA_ARGS Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * sync src/ from master Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * remove Makefile from core Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
1 parent
0066ca4ea2
commit
2657df2293
3321 files changed
+28699
-11447
No files matched your search
+21
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import { AddCluster } from "./add-cluster";
|
||||
import { routeSpecificComponentInjectionToken } from "../../routes/route-specific-component-injection-token";
|
||||
import addClusterRouteInjectable from "../../../common/front-end-routing/routes/add-cluster/add-cluster-route.injectable";
|
||||
|
||||
const addClusterRouteComponentInjectable = getInjectable({
|
||||
id: "add-cluster-route-component",
|
||||
|
||||
instantiate: (di) => ({
|
||||
route: di.inject(addClusterRouteInjectable),
|
||||
Component: AddCluster,
|
||||
}),
|
||||
|
||||
injectionToken: routeSpecificComponentInjectionToken,
|
||||
});
|
||||
|
||||
export default addClusterRouteComponentInjectable;
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
@import "../../components/mixins.scss";
|
||||
|
||||
.AddClusters {
|
||||
--flex-gap: calc(var(--unit) * 2);
|
||||
|
||||
code {
|
||||
color: rgb(236, 64, 122);
|
||||
}
|
||||
|
||||
a[href] {
|
||||
color: var(--colorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
.editor {
|
||||
min-height: 600px;
|
||||
max-height: 600px;
|
||||
border: 1px solid var(--colorVague);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
@include theme-light {
|
||||
.editor {
|
||||
border-color: var(--borderFaintColor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import styles from "./add-cluster.module.scss";
|
||||
|
||||
import type { KubeConfig } from "@kubernetes/client-node";
|
||||
import fse from "fs-extra";
|
||||
import { debounce } from "lodash";
|
||||
import { action, computed, makeObservable, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import * as uuid from "uuid";
|
||||
import { loadConfigFromString, splitConfig } from "../../../common/kube-helpers";
|
||||
import { docsUrl } from "../../../common/vars";
|
||||
import { isDefined, iter } from "../../utils";
|
||||
import { Button } from "../button";
|
||||
import type { ShowNotification } from "../notifications";
|
||||
import { SettingLayout } from "../layout/setting-layout";
|
||||
import { MonacoEditor } from "../monaco-editor";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import getCustomKubeConfigFilePathInjectable from "../../../common/app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable";
|
||||
import type { NavigateToCatalog } from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";
|
||||
import navigateToCatalogInjectable from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";
|
||||
import type { EmitAppEvent } from "../../../common/app-event-bus/emit-event.injectable";
|
||||
import emitAppEventInjectable from "../../../common/app-event-bus/emit-event.injectable";
|
||||
import type { GetDirnameOfPath } from "../../../common/path/get-dirname.injectable";
|
||||
import getDirnameOfPathInjectable from "../../../common/path/get-dirname.injectable";
|
||||
import showSuccessNotificationInjectable from "../notifications/show-success-notification.injectable";
|
||||
import showErrorNotificationInjectable from "../notifications/show-error-notification.injectable";
|
||||
|
||||
interface Option {
|
||||
config: KubeConfig;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Dependencies {
|
||||
getCustomKubeConfigDirectory: (directoryName: string) => string;
|
||||
navigateToCatalog: NavigateToCatalog;
|
||||
getDirnameOfPath: GetDirnameOfPath;
|
||||
emitAppEvent: EmitAppEvent;
|
||||
showSuccessNotification: ShowNotification;
|
||||
showErrorNotification: ShowNotification;
|
||||
}
|
||||
|
||||
function getContexts(config: KubeConfig): Map<string, Option> {
|
||||
return new Map(
|
||||
splitConfig(config)
|
||||
.map(({ config, validationResult }) => [config.currentContext, {
|
||||
config,
|
||||
error: validationResult.error?.toString(),
|
||||
}]),
|
||||
);
|
||||
}
|
||||
|
||||
@observer
|
||||
class NonInjectedAddCluster extends React.Component<Dependencies> {
|
||||
@observable kubeContexts = observable.map<string, Option>();
|
||||
@observable customConfig = "";
|
||||
@observable isWaiting = false;
|
||||
@observable errors: string[] = [];
|
||||
|
||||
constructor(props: Dependencies) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.emitAppEvent({ name: "cluster-add", action: "start" });
|
||||
}
|
||||
|
||||
@computed get allErrors(): string[] {
|
||||
return [
|
||||
...this.errors,
|
||||
...iter.map(this.kubeContexts.values(), ({ error }) => error),
|
||||
].filter(isDefined);
|
||||
}
|
||||
|
||||
readonly refreshContexts = debounce(action(() => {
|
||||
const { config, error } = loadConfigFromString(this.customConfig.trim() || "{}");
|
||||
|
||||
this.kubeContexts.replace(getContexts(config));
|
||||
|
||||
if (error) {
|
||||
this.errors.push(error.toString());
|
||||
}
|
||||
|
||||
if (config.contexts.length === 0) {
|
||||
this.errors.push('No contexts defined, either missing the "contexts" field, or it is empty.');
|
||||
}
|
||||
}), 500);
|
||||
|
||||
addClusters = action(async () => {
|
||||
this.isWaiting = true;
|
||||
this.props.emitAppEvent({ name: "cluster-add", action: "click" });
|
||||
|
||||
try {
|
||||
const absPath = this.props.getCustomKubeConfigDirectory(uuid.v4());
|
||||
|
||||
await fse.ensureDir(this.props.getDirnameOfPath(absPath));
|
||||
await fse.writeFile(absPath, this.customConfig.trim(), { encoding: "utf-8", mode: 0o600 });
|
||||
|
||||
this.props.showSuccessNotification(`Successfully added ${this.kubeContexts.size} new cluster(s)`);
|
||||
|
||||
return this.props.navigateToCatalog();
|
||||
} catch (error) {
|
||||
this.props.showErrorNotification(`Failed to add clusters: ${error}`);
|
||||
}
|
||||
});
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SettingLayout className={styles.AddClusters} data-testid="add-cluster-page">
|
||||
<h2>Add Clusters from Kubeconfig</h2>
|
||||
<p>
|
||||
{"Clusters added here are "}
|
||||
<b>not</b>
|
||||
{" merged into the "}
|
||||
<code>~/.kube/config</code>
|
||||
{" file. "}
|
||||
<a
|
||||
href={`${docsUrl}/getting-started/add-cluster/`}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Read more about adding clusters.
|
||||
</a>
|
||||
</p>
|
||||
<div className="flex column">
|
||||
<MonacoEditor
|
||||
autoFocus
|
||||
className={styles.editor}
|
||||
value={this.customConfig}
|
||||
onChange={value => {
|
||||
this.customConfig = value;
|
||||
this.errors.length = 0;
|
||||
this.refreshContexts();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{this.allErrors.length > 0 && (
|
||||
<>
|
||||
<h3>KubeConfig Yaml Validation Errors:</h3>
|
||||
{this.allErrors.map(error => <div key={error} className="error">{error}</div>)}
|
||||
</>
|
||||
)}
|
||||
<div className="actions-panel">
|
||||
<Button
|
||||
primary
|
||||
disabled={this.kubeContexts.size === 0}
|
||||
label={this.kubeContexts.size === 1 ? "Add cluster" : "Add clusters"}
|
||||
onClick={this.addClusters}
|
||||
waiting={this.isWaiting}
|
||||
tooltip={this.kubeContexts.size === 0 || "Paste in at least one cluster to add."}
|
||||
tooltipOverrideDisabled
|
||||
/>
|
||||
</div>
|
||||
</SettingLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const AddCluster = withInjectables<Dependencies>(NonInjectedAddCluster, {
|
||||
getProps: (di) => ({
|
||||
getCustomKubeConfigDirectory: di.inject(getCustomKubeConfigFilePathInjectable),
|
||||
navigateToCatalog: di.inject(navigateToCatalogInjectable),
|
||||
getDirnameOfPath: di.inject(getDirnameOfPathInjectable),
|
||||
emitAppEvent: di.inject(emitAppEventInjectable),
|
||||
showSuccessNotification: di.inject(showSuccessNotificationInjectable),
|
||||
showErrorNotification: di.inject(showErrorNotificationInjectable),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
export * from "./add-cluster";
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import React from "react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { CatalogCategorySpec } from "../../../../common/catalog";
|
||||
import { CatalogCategory } from "../../../../common/catalog";
|
||||
import { CatalogAddButton } from "../catalog-add-button";
|
||||
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
|
||||
import { type DiRender, renderFor } from "../../test-utils/renderFor";
|
||||
|
||||
class TestCatalogCategory extends CatalogCategory {
|
||||
public readonly apiVersion = "catalog.k8slens.dev/v1alpha1";
|
||||
public readonly kind = "CatalogCategory";
|
||||
public metadata = {
|
||||
name: "Test Category",
|
||||
icon: "",
|
||||
};
|
||||
public spec: CatalogCategorySpec = {
|
||||
group: "entity.k8slens.dev",
|
||||
versions: [],
|
||||
names: {
|
||||
kind: "Test",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("CatalogAddButton", () => {
|
||||
let render: DiRender;
|
||||
|
||||
beforeEach(() => {
|
||||
const di = getDiForUnitTesting({ doGeneralOverrides: true });
|
||||
|
||||
render = renderFor(di);
|
||||
});
|
||||
|
||||
it("opens Add menu", async () => {
|
||||
const category = new TestCatalogCategory();
|
||||
|
||||
category.on("catalogAddMenu", ctx => {
|
||||
ctx.menuItems.push(
|
||||
{
|
||||
icon: "text_snippet",
|
||||
title: "Add from kubeconfig",
|
||||
onClick: () => {},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
render(<CatalogAddButton category={category}/>);
|
||||
|
||||
userEvent.hover(screen.getByLabelText("SpeedDial CatalogAddButton"));
|
||||
await screen.findByTitle("Add from kubeconfig");
|
||||
});
|
||||
|
||||
it("filters menu items", async () => {
|
||||
const category = new TestCatalogCategory();
|
||||
|
||||
category.on("catalogAddMenu", ctx => {
|
||||
ctx.menuItems.push(
|
||||
{
|
||||
icon: "text_snippet",
|
||||
title: "foobar",
|
||||
onClick: () => {},
|
||||
},
|
||||
);
|
||||
ctx.menuItems.push(
|
||||
{
|
||||
icon: "text_snippet",
|
||||
title: "Add from kubeconfig",
|
||||
onClick: () => {},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
category.addMenuFilter(item => item.title === "foobar");
|
||||
|
||||
render(<CatalogAddButton category={category}/>);
|
||||
|
||||
userEvent.hover(screen.getByLabelText("SpeedDial CatalogAddButton"));
|
||||
|
||||
await expect(screen.findByTitle("Add from kubeconfig"))
|
||||
.rejects
|
||||
.toThrow();
|
||||
await screen.findByTitle("foobar");
|
||||
});
|
||||
});
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom/extend-expect";
|
||||
import type { CatalogCategorySpec } from "../../../../common/catalog";
|
||||
import { CatalogCategory } from "../../../../common/catalog";
|
||||
import { CatalogCategoryLabel } from "../catalog-category-label";
|
||||
|
||||
class CatalogCategoryWithoutBadge extends CatalogCategory {
|
||||
public readonly apiVersion = "catalog.k8slens.dev/v1alpha1";
|
||||
public readonly kind = "CatalogCategory";
|
||||
|
||||
public metadata = {
|
||||
name: "Test Category",
|
||||
icon: "",
|
||||
};
|
||||
|
||||
public spec: CatalogCategorySpec = {
|
||||
group: "entity.k8slens.dev",
|
||||
versions: [],
|
||||
names: {
|
||||
kind: "Test",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class CatalogCategoryWithBadge extends CatalogCategoryWithoutBadge {
|
||||
getBadge() {
|
||||
return (<div>Test Badge</div>);
|
||||
}
|
||||
}
|
||||
|
||||
describe("CatalogCategoryLabel", () => {
|
||||
it("renders without a badge", async () => {
|
||||
const category = new CatalogCategoryWithoutBadge();
|
||||
|
||||
render(<CatalogCategoryLabel category={category}/>);
|
||||
|
||||
expect(await screen.findByText("Test Category")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with a badge", async () => {
|
||||
const category = new CatalogCategoryWithBadge();
|
||||
|
||||
render(<CatalogCategoryLabel category={category}/>);
|
||||
|
||||
expect(await screen.findByText("Test Category")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Test Badge")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import type { CatalogCategoryMetadata, CatalogCategorySpec } from "../../../../common/catalog";
|
||||
import { CatalogEntity, categoryVersion } from "../../../../common/catalog";
|
||||
import { CatalogCategory } from "../../../api/catalog-entity";
|
||||
import { noop } from "../../../utils";
|
||||
import type { CatalogEntityStore } from "../catalog-entity-store/catalog-entity.store";
|
||||
import { catalogEntityStore } from "../catalog-entity-store/catalog-entity.store";
|
||||
|
||||
class TestEntityOne extends CatalogEntity {
|
||||
public static readonly apiVersion: string = "entity.k8slens.dev/v1alpha1";
|
||||
public static readonly kind: string = "TestEntityOne";
|
||||
|
||||
public readonly apiVersion = TestEntityOne.apiVersion;
|
||||
public readonly kind = TestEntityOne.kind;
|
||||
}
|
||||
|
||||
class TestEntityTwo extends CatalogEntity {
|
||||
public static readonly apiVersion: string = "entity.k8slens.dev/v1alpha1";
|
||||
public static readonly kind: string = "TestEntityTwo";
|
||||
|
||||
public readonly apiVersion = TestEntityTwo.apiVersion;
|
||||
public readonly kind = TestEntityTwo.kind;
|
||||
}
|
||||
|
||||
class TestCategoryOne extends CatalogCategory {
|
||||
apiVersion = "catalog.k8slens.dev/v1alpha1";
|
||||
kind = "CatalogCategory";
|
||||
metadata: CatalogCategoryMetadata = {
|
||||
icon: "dash",
|
||||
name: "test-one",
|
||||
};
|
||||
spec: CatalogCategorySpec = {
|
||||
group: "entity.k8slens.dev",
|
||||
versions: [
|
||||
categoryVersion("v1alpha1", TestEntityOne),
|
||||
],
|
||||
names: {
|
||||
kind: "KubernetesCluster",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class TestCategoryTwo extends CatalogCategory {
|
||||
apiVersion = "catalog.k8slens.dev/v1alpha1";
|
||||
kind = "CatalogCategory";
|
||||
metadata: CatalogCategoryMetadata = {
|
||||
icon: "dash",
|
||||
name: "test-two",
|
||||
};
|
||||
spec: CatalogCategorySpec = {
|
||||
group: "entity.k8slens.dev",
|
||||
versions: [
|
||||
categoryVersion("v1alpha1", TestEntityTwo),
|
||||
],
|
||||
names: {
|
||||
kind: "KubernetesCluster",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("CatalogEntityStore", () => {
|
||||
describe("getTotalCount", () => {
|
||||
let store: CatalogEntityStore;
|
||||
let testCategoryOne: TestCategoryOne;
|
||||
let testCategoryTwo: TestCategoryTwo;
|
||||
|
||||
beforeEach(() => {
|
||||
const entityItems = [
|
||||
new TestEntityOne({
|
||||
metadata: {
|
||||
labels: {},
|
||||
name: "my-test-one",
|
||||
uid: "1",
|
||||
},
|
||||
spec: {},
|
||||
status: {
|
||||
phase: "unknown",
|
||||
},
|
||||
}),
|
||||
new TestEntityOne({
|
||||
metadata: {
|
||||
labels: {},
|
||||
name: "my-test-two",
|
||||
uid: "2",
|
||||
},
|
||||
spec: {},
|
||||
status: {
|
||||
phase: "unknown",
|
||||
},
|
||||
}),
|
||||
new TestEntityTwo({
|
||||
metadata: {
|
||||
labels: {},
|
||||
name: "my-test-three",
|
||||
uid: "3",
|
||||
},
|
||||
spec: {},
|
||||
status: {
|
||||
phase: "unknown",
|
||||
},
|
||||
}),
|
||||
new TestEntityTwo({
|
||||
metadata: {
|
||||
labels: {},
|
||||
name: "my-test-four",
|
||||
uid: "4",
|
||||
},
|
||||
spec: {},
|
||||
status: {
|
||||
phase: "unknown",
|
||||
},
|
||||
}),
|
||||
new TestEntityTwo({
|
||||
metadata: {
|
||||
labels: {},
|
||||
name: "my-test-five",
|
||||
uid: "5",
|
||||
},
|
||||
spec: {},
|
||||
status: {
|
||||
phase: "unknown",
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
testCategoryOne = new TestCategoryOne();
|
||||
testCategoryTwo = new TestCategoryTwo();
|
||||
store = catalogEntityStore({
|
||||
catalogRegistry: {
|
||||
items: [
|
||||
testCategoryOne,
|
||||
testCategoryTwo,
|
||||
],
|
||||
},
|
||||
entityRegistry: {
|
||||
onRun: noop,
|
||||
filteredItems: entityItems,
|
||||
getItemsForCategory: <T extends CatalogEntity>(category: CatalogCategory): T[] => {
|
||||
return entityItems.filter(item => category.spec.versions.some(version => item instanceof version.entityClass)) as T[];
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("given no active category, returns count of all kinds", () => {
|
||||
expect(store.getTotalCount()).toBe(5);
|
||||
});
|
||||
|
||||
it("given active category is TestCategoryOne, only returns count for those declared kinds", () => {
|
||||
store.activeCategory.set(testCategoryOne);
|
||||
expect(store.getTotalCount()).toBe(2);
|
||||
});
|
||||
|
||||
it("given active category is TestCategoryTwo, only returns count for those declared kinds", () => {
|
||||
store.activeCategory.set(testCategoryTwo);
|
||||
expect(store.getTotalCount()).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import type { DiContainer } from "@ogre-tools/injectable";
|
||||
import type { CatalogCategorySpec } from "../../../../common/catalog";
|
||||
import { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
|
||||
import { CatalogCategory } from "../../../api/catalog-entity";
|
||||
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
|
||||
import type { AdditionalCategoryColumnRegistration, CategoryColumnRegistration } from "../custom-category-columns";
|
||||
import type { CategoryColumns, GetCategoryColumnsParams } from "../columns/get.injectable";
|
||||
import getCategoryColumnsInjectable from "../columns/get.injectable";
|
||||
import hotbarStoreInjectable from "../../../../common/hotbars/store.injectable";
|
||||
import extensionInjectable from "../../../../extensions/extension-loader/extension/extension.injectable";
|
||||
import currentlyInClusterFrameInjectable from "../../../routes/currently-in-cluster-frame.injectable";
|
||||
|
||||
class TestCategory extends CatalogCategory {
|
||||
apiVersion = "catalog.k8slens.dev/v1alpha1";
|
||||
kind = "CatalogCategory";
|
||||
metadata = {
|
||||
name: "Test",
|
||||
icon: "question_mark",
|
||||
};
|
||||
spec: CatalogCategorySpec = {
|
||||
group: "foo.bar.bat",
|
||||
names: {
|
||||
kind: "Test",
|
||||
},
|
||||
versions: [],
|
||||
};
|
||||
|
||||
constructor(columns?: CategoryColumnRegistration[]) {
|
||||
super();
|
||||
this.spec = {
|
||||
displayColumns: columns,
|
||||
...this.spec,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("Custom Category Columns", () => {
|
||||
let di: DiContainer;
|
||||
let getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns;
|
||||
|
||||
beforeEach(() => {
|
||||
di = getDiForUnitTesting({ doGeneralOverrides: true });
|
||||
|
||||
di.override(hotbarStoreInjectable, () => ({}));
|
||||
di.override(currentlyInClusterFrameInjectable, () => false);
|
||||
|
||||
getCategoryColumns = di.inject(getCategoryColumnsInjectable);
|
||||
});
|
||||
|
||||
describe("without extensions", () => {
|
||||
it("should contain a kind column if activeCategory is falsy", () => {
|
||||
expect(getCategoryColumns({ activeCategory: null }).renderTableHeader.find(elem => elem?.title === "Kind")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not contain a kind column if activeCategory is truthy", () => {
|
||||
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "Kind")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should include the default columns if the provided category doesn't provide any", () => {
|
||||
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "Source")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not include the default columns if the provided category provides any", () => {
|
||||
expect(getCategoryColumns({ activeCategory: new TestCategory([]) }).renderTableHeader.find(elem => elem?.title === "Source")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should include the displayColumns from the provided category", () => {
|
||||
const columns: CategoryColumnRegistration[] = [
|
||||
{
|
||||
id: "foo",
|
||||
renderCell: () => null,
|
||||
titleProps: {
|
||||
title: "Foo",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(getCategoryColumns({ activeCategory: new TestCategory(columns) }).renderTableHeader.find(elem => elem?.title === "Foo")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("with extensions", () => {
|
||||
beforeEach(() => {
|
||||
const ext = di.inject(extensionInjectable, new (class extends LensRendererExtension {
|
||||
additionalCategoryColumns = [
|
||||
{
|
||||
group: "foo.bar.bat",
|
||||
id: "high",
|
||||
kind: "Test",
|
||||
renderCell: () => "",
|
||||
titleProps: {
|
||||
title: "High",
|
||||
},
|
||||
} as AdditionalCategoryColumnRegistration,
|
||||
{
|
||||
group: "foo.bar",
|
||||
id: "high",
|
||||
kind: "Test",
|
||||
renderCell: () => "",
|
||||
titleProps: {
|
||||
title: "High2",
|
||||
},
|
||||
} as AdditionalCategoryColumnRegistration,
|
||||
];
|
||||
})({
|
||||
absolutePath: "/some-absolute-path",
|
||||
id: "some-id",
|
||||
isBundled: false,
|
||||
isCompatible: true,
|
||||
isEnabled: true,
|
||||
manifest: {
|
||||
engines: {
|
||||
lens: "",
|
||||
},
|
||||
name: "some-extension-name",
|
||||
version: "1.0.0",
|
||||
},
|
||||
manifestPath: "/some-manifest-path",
|
||||
}));
|
||||
|
||||
ext.register();
|
||||
});
|
||||
|
||||
it("should include columns from extensions that match", () => {
|
||||
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "High")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not include columns from extensions that don't match", () => {
|
||||
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "High2")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import type { DiContainer } from "@ogre-tools/injectable";
|
||||
import { computed } from "mobx";
|
||||
import type React from "react";
|
||||
import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
|
||||
import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable";
|
||||
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
|
||||
import type { CustomCategoryViewRegistration } from "../custom-views";
|
||||
import customCategoryViewsInjectable from "../custom-views.injectable";
|
||||
|
||||
describe("Custom Category Views", () => {
|
||||
let di: DiContainer;
|
||||
|
||||
beforeEach(() => {
|
||||
di = getDiForUnitTesting({ doGeneralOverrides: true });
|
||||
});
|
||||
|
||||
it("should order items correctly over all extensions", () => {
|
||||
const component1 = (): React.ReactNode => null;
|
||||
const component2 = (): React.ReactNode => null;
|
||||
|
||||
di.override(rendererExtensionsInjectable, () => computed(() => [
|
||||
{
|
||||
customCategoryViews: [
|
||||
{
|
||||
components: {
|
||||
View: component1,
|
||||
},
|
||||
group: "foo",
|
||||
kind: "bar",
|
||||
priority: 100,
|
||||
} as CustomCategoryViewRegistration,
|
||||
],
|
||||
},
|
||||
{
|
||||
customCategoryViews: [
|
||||
{
|
||||
components: {
|
||||
View: component2,
|
||||
},
|
||||
group: "foo",
|
||||
kind: "bar",
|
||||
priority: 95,
|
||||
} as CustomCategoryViewRegistration,
|
||||
],
|
||||
},
|
||||
] as LensRendererExtension[]));
|
||||
|
||||
const customCategoryViews = di.inject(customCategoryViewsInjectable);
|
||||
const { after = [] } = customCategoryViews.get().get("foo")?.get("bar") ?? {};
|
||||
|
||||
expect(after[0].View).toBe(component2);
|
||||
expect(after[1].View).toBe(component1);
|
||||
});
|
||||
|
||||
it("should put put priority < 50 items in before", () => {
|
||||
const component1 = (): React.ReactNode => null;
|
||||
const component2 = (): React.ReactNode => null;
|
||||
|
||||
di.override(rendererExtensionsInjectable, () => computed(() => [
|
||||
{
|
||||
customCategoryViews: [
|
||||
{
|
||||
components: {
|
||||
View: component1,
|
||||
},
|
||||
group: "foo",
|
||||
kind: "bar",
|
||||
priority: 40,
|
||||
} as CustomCategoryViewRegistration,
|
||||
],
|
||||
},
|
||||
{
|
||||
customCategoryViews: [
|
||||
{
|
||||
components: {
|
||||
View: component2,
|
||||
},
|
||||
group: "foo",
|
||||
kind: "bar",
|
||||
priority: 95,
|
||||
} as CustomCategoryViewRegistration,
|
||||
],
|
||||
},
|
||||
] as LensRendererExtension[]));
|
||||
|
||||
const customCategoryViews = di.inject(customCategoryViewsInjectable);
|
||||
const { before = [] } = customCategoryViews.get().get("foo")?.get("bar") ?? {};
|
||||
|
||||
expect(before[0].View).toBe(component1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
.CatalogAddButton {
|
||||
position: absolute;
|
||||
right: 40px;
|
||||
bottom: 30px;
|
||||
|
||||
.MuiFab-primary {
|
||||
background-color: var(--blue);
|
||||
border-radius: 50%;
|
||||
|
||||
&:hover {
|
||||
background-color: #317bb3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.catalogSpeedDialPopper {
|
||||
.MuiTooltip-tooltip {
|
||||
background-color: #222;
|
||||
font-size: 12px
|
||||
}
|
||||
|
||||
.MuiTooltip-arrow {
|
||||
color: #222;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import "./catalog-add-button.scss";
|
||||
import React from "react";
|
||||
import { SpeedDial, SpeedDialAction } from "@material-ui/lab";
|
||||
import { Icon } from "../icon";
|
||||
import { observer } from "mobx-react";
|
||||
import { observable, makeObservable, action } from "mobx";
|
||||
import { autoBind } from "../../../common/utils";
|
||||
import type { CatalogCategory, CatalogEntityAddMenu } from "../../api/catalog-entity";
|
||||
import { EventEmitter } from "events";
|
||||
import type { CatalogCategoryRegistry } from "../../../common/catalog";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import catalogCategoryRegistryInjectable from "../../../common/catalog/category-registry.injectable";
|
||||
import type { Navigate } from "../../navigation/navigate.injectable";
|
||||
import navigateInjectable from "../../navigation/navigate.injectable";
|
||||
|
||||
export interface CatalogAddButtonProps {
|
||||
category: CatalogCategory;
|
||||
}
|
||||
|
||||
type CategoryId = string;
|
||||
|
||||
interface Dependencies {
|
||||
catalogCategoryRegistry: CatalogCategoryRegistry;
|
||||
navigate: Navigate;
|
||||
}
|
||||
|
||||
@observer
|
||||
class NonInjectedCatalogAddButton extends React.Component<CatalogAddButtonProps & Dependencies> {
|
||||
@observable protected isOpen = false;
|
||||
@observable menuItems = new Map<CategoryId, CatalogEntityAddMenu[]>();
|
||||
|
||||
constructor(props: CatalogAddButtonProps & Dependencies) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
autoBind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.updateMenuItems();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: CatalogAddButtonProps) {
|
||||
if (prevProps.category != this.props.category) {
|
||||
this.updateMenuItems();
|
||||
}
|
||||
}
|
||||
|
||||
get categories() {
|
||||
return this.props.catalogCategoryRegistry.filteredItems;
|
||||
}
|
||||
|
||||
@action
|
||||
updateMenuItems() {
|
||||
this.menuItems.clear();
|
||||
|
||||
if (this.props.category) {
|
||||
this.updateCategoryItems(this.props.category);
|
||||
} else {
|
||||
// Show menu items from all categories
|
||||
this.categories.forEach(this.updateCategoryItems);
|
||||
}
|
||||
}
|
||||
|
||||
updateCategoryItems = action((category: CatalogCategory) => {
|
||||
if (category instanceof EventEmitter) {
|
||||
const menuItems: CatalogEntityAddMenu[] = [];
|
||||
|
||||
category.emit("catalogAddMenu", {
|
||||
navigate: this.props.navigate,
|
||||
menuItems,
|
||||
});
|
||||
this.menuItems.set(category.getId(), menuItems);
|
||||
}
|
||||
});
|
||||
|
||||
getCategoryFilteredItems = (category: CatalogCategory) => {
|
||||
return category.filteredItems(this.menuItems.get(category.getId()) || []);
|
||||
};
|
||||
|
||||
onOpen() {
|
||||
this.isOpen = true;
|
||||
}
|
||||
|
||||
onClose() {
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
onButtonClick() {
|
||||
const defaultAction = this.items.find(item => item.defaultAction)?.onClick;
|
||||
const clickAction = defaultAction || (this.items.length === 1 ? this.items[0].onClick : null);
|
||||
|
||||
clickAction?.();
|
||||
}
|
||||
|
||||
get items() {
|
||||
const { category } = this.props;
|
||||
|
||||
return category ? this.getCategoryFilteredItems(category) :
|
||||
this.categories.map(this.getCategoryFilteredItems).flat();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SpeedDial
|
||||
className="CatalogAddButton"
|
||||
ariaLabel="SpeedDial CatalogAddButton"
|
||||
open={this.isOpen}
|
||||
onOpen={this.onOpen}
|
||||
onClose={this.onClose}
|
||||
icon={<Icon material="add" />}
|
||||
direction="up"
|
||||
onClick={this.onButtonClick}
|
||||
>
|
||||
{this.items.map((menuItem, index) => {
|
||||
return (
|
||||
<SpeedDialAction
|
||||
key={index}
|
||||
icon={<Icon material={menuItem.icon}/>}
|
||||
tooltipTitle={menuItem.title}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
menuItem.onClick();
|
||||
}}
|
||||
TooltipClasses={{
|
||||
popper: "catalogSpeedDialPopper",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SpeedDial>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const CatalogAddButton = withInjectables<Dependencies, CatalogAddButtonProps>(NonInjectedCatalogAddButton, {
|
||||
getProps: (di, props) => ({
|
||||
...props,
|
||||
catalogCategoryRegistry: di.inject(catalogCategoryRegistryInjectable),
|
||||
navigate: di.inject(navigateInjectable),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
export const browseCatalogTab = "browse";
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import React from "react";
|
||||
import type { CatalogCategory } from "../../../common/catalog/catalog-entity";
|
||||
|
||||
interface CatalogCategoryLabelProps {
|
||||
category: CatalogCategory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display label for Catalog Category for the Catalog menu
|
||||
*/
|
||||
export const CatalogCategoryLabel = ({ category }: CatalogCategoryLabelProps) => {
|
||||
const badge = category.getBadge();
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<div>{category.metadata.name}</div>
|
||||
{badge ? (<div className="flex items-center">{badge}</div>) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { cssNames, hasDefiniteField } from "../../utils";
|
||||
import type { MenuActionsProps } from "../menu/menu-actions";
|
||||
import { MenuActions } from "../menu/menu-actions";
|
||||
import type { CatalogEntity, CatalogEntityContextMenu } from "../../api/catalog-entity";
|
||||
import { observer } from "mobx-react";
|
||||
import { observable } from "mobx";
|
||||
import { MenuItem } from "../menu";
|
||||
import { Icon } from "../icon";
|
||||
import { HotbarToggleMenuItem } from "./hotbar-toggle-menu-item";
|
||||
import type { VisitEntityContextMenu } from "../../../common/catalog/visit-entity-context-menu.injectable";
|
||||
import visitEntityContextMenuInjectable from "../../../common/catalog/visit-entity-context-menu.injectable";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import type { Navigate } from "../../navigation/navigate.injectable";
|
||||
import navigateInjectable from "../../navigation/navigate.injectable";
|
||||
import type { NormalizeCatalogEntityContextMenu } from "../../catalog/normalize-menu-item.injectable";
|
||||
import normalizeCatalogEntityContextMenuInjectable from "../../catalog/normalize-menu-item.injectable";
|
||||
|
||||
export interface CatalogEntityDrawerMenuProps<Entity extends CatalogEntity> extends MenuActionsProps {
|
||||
entity: Entity;
|
||||
}
|
||||
|
||||
interface Dependencies {
|
||||
normalizeMenuItem: NormalizeCatalogEntityContextMenu;
|
||||
navigate: Navigate;
|
||||
visitEntityContextMenu: VisitEntityContextMenu;
|
||||
}
|
||||
|
||||
@observer
|
||||
class NonInjectedCatalogEntityDrawerMenu<T extends CatalogEntity> extends React.Component<CatalogEntityDrawerMenuProps<T> & Dependencies> {
|
||||
private readonly menuItems = observable.array<CatalogEntityContextMenu>();
|
||||
|
||||
componentDidMount() {
|
||||
this.props.visitEntityContextMenu(this.props.entity, {
|
||||
menuItems: this.menuItems,
|
||||
navigate: this.props.navigate,
|
||||
});
|
||||
}
|
||||
|
||||
getMenuItems(entity: T): React.ReactChild[] {
|
||||
if (!entity) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items = this.menuItems
|
||||
.map(this.props.normalizeMenuItem)
|
||||
.filter(hasDefiniteField("icon"))
|
||||
.map(menuItem => (
|
||||
<MenuItem key={menuItem.title} onClick={menuItem.onClick}>
|
||||
<Icon
|
||||
interactive
|
||||
tooltip={menuItem.title}
|
||||
{...{ [Icon.isSvg(menuItem.icon) ? "svg" : "material"]: menuItem.icon }}
|
||||
/>
|
||||
</MenuItem>
|
||||
));
|
||||
|
||||
items.push(
|
||||
<HotbarToggleMenuItem
|
||||
key="hotbar-toggle"
|
||||
entity={entity}
|
||||
addContent={(
|
||||
<Icon
|
||||
material="push_pin"
|
||||
interactive
|
||||
small
|
||||
tooltip="Add to Hotbar"
|
||||
/>
|
||||
)}
|
||||
removeContent={(
|
||||
<Icon
|
||||
svg="push_off"
|
||||
interactive
|
||||
small
|
||||
tooltip="Remove from Hotbar"
|
||||
/>
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, entity, ...menuProps } = this.props;
|
||||
|
||||
if (!this.menuItems.length || !entity.isEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuActions
|
||||
id={`menu-actions-for-catalog-entity-drawer-menu-${entity.getId()}`}
|
||||
className={cssNames("CatalogEntityDrawerMenu", className)}
|
||||
toolbar
|
||||
{...menuProps}
|
||||
>
|
||||
{this.getMenuItems(entity)}
|
||||
</MenuActions>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const CatalogEntityDrawerMenu = withInjectables<Dependencies, CatalogEntityDrawerMenuProps<CatalogEntity>>(NonInjectedCatalogEntityDrawerMenu, {
|
||||
getProps: (di, props) => ({
|
||||
...props,
|
||||
visitEntityContextMenu: di.inject(visitEntityContextMenuInjectable),
|
||||
normalizeMenuItem: di.inject(normalizeCatalogEntityContextMenuInjectable),
|
||||
navigate: di.inject(navigateInjectable),
|
||||
}),
|
||||
}) as <Entity extends CatalogEntity>(props: CatalogEntityDrawerMenuProps<Entity>) => JSX.Element;
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import { catalogEntityStore } from "./catalog-entity.store";
|
||||
import catalogEntityRegistryInjectable from "../../../api/catalog/entity/registry.injectable";
|
||||
import catalogCategoryRegistryInjectable from "../../../../common/catalog/category-registry.injectable";
|
||||
|
||||
const catalogEntityStoreInjectable = getInjectable({
|
||||
id: "catalog-entity-store",
|
||||
|
||||
instantiate: (di) => catalogEntityStore({
|
||||
entityRegistry: di.inject(catalogEntityRegistryInjectable),
|
||||
catalogRegistry: di.inject(catalogCategoryRegistryInjectable),
|
||||
}),
|
||||
});
|
||||
|
||||
export default catalogEntityStoreInjectable;
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import type { IComputedValue, IObservableValue } from "mobx";
|
||||
import { computed, observable, reaction } from "mobx";
|
||||
import type { CatalogEntityRegistry } from "../../../api/catalog/entity/registry";
|
||||
import type { CatalogEntity } from "../../../api/catalog-entity";
|
||||
import type { CatalogCategory, CatalogCategoryRegistry } from "../../../../common/catalog";
|
||||
import type { Disposer } from "../../../../common/utils";
|
||||
import { disposer } from "../../../../common/utils";
|
||||
import type { ItemListStore } from "../../item-object-list";
|
||||
|
||||
type EntityRegistry = Pick<CatalogEntityRegistry, "getItemsForCategory" | "filteredItems" | "onRun">;
|
||||
type CatalogRegistry = Pick<CatalogCategoryRegistry, "items">;
|
||||
|
||||
interface Dependencies {
|
||||
entityRegistry: EntityRegistry;
|
||||
catalogRegistry: CatalogRegistry;
|
||||
}
|
||||
|
||||
export type CatalogEntityStore = ItemListStore<CatalogEntity, false> & {
|
||||
readonly entities: IComputedValue<CatalogEntity[]>;
|
||||
readonly activeCategory: IObservableValue<CatalogCategory | undefined>;
|
||||
readonly selectedItemId: IObservableValue<string | undefined>;
|
||||
readonly selectedItem: IComputedValue<CatalogEntity | undefined>;
|
||||
watch(): Disposer;
|
||||
onRun(entity: CatalogEntity): void;
|
||||
};
|
||||
|
||||
export function catalogEntityStore({
|
||||
entityRegistry,
|
||||
catalogRegistry,
|
||||
}: Dependencies): CatalogEntityStore {
|
||||
const activeCategory = observable.box<CatalogCategory | undefined>(undefined);
|
||||
const selectedItemId = observable.box<string | undefined>(undefined);
|
||||
const entities = computed(() => {
|
||||
const category = activeCategory.get();
|
||||
|
||||
return category
|
||||
? entityRegistry.getItemsForCategory(category, { filtered: true })
|
||||
: entityRegistry.filteredItems;
|
||||
});
|
||||
const selectedItem = computed(() => {
|
||||
const id = selectedItemId.get();
|
||||
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entities.get().find(entity => entity.getId() === id);
|
||||
});
|
||||
const loadAll = () => {
|
||||
const category = activeCategory.get();
|
||||
|
||||
if (category) {
|
||||
category.emit("load");
|
||||
} else {
|
||||
for (const category of catalogRegistry.items) {
|
||||
category.emit("load");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
entities,
|
||||
selectedItem,
|
||||
activeCategory,
|
||||
selectedItemId,
|
||||
watch: () => disposer(
|
||||
reaction(() => entities.get(), loadAll),
|
||||
reaction(() => activeCategory.get(), loadAll, { delay: 100 }),
|
||||
),
|
||||
onRun: entity => entityRegistry.onRun(entity),
|
||||
failedLoading: false,
|
||||
getTotalCount: () => entities.get().length,
|
||||
isLoaded: true,
|
||||
isSelected: (item) => item.getId() === selectedItemId.get(),
|
||||
isSelectedAll: () => false,
|
||||
pickOnlySelected: () => [],
|
||||
toggleSelection: () => {},
|
||||
toggleSelectionAll: () => {},
|
||||
removeSelectedItems: async () => {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
.bordered {
|
||||
border-top: 1px solid var(--inputControlBorder);
|
||||
}
|
||||
|
||||
.parent {
|
||||
color: var(--textColorAccent);
|
||||
font-size: small;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.catalog {
|
||||
font-size: medium;
|
||||
padding: var(--padding) calc(var(--padding) * 1.5);
|
||||
font-weight: bold;
|
||||
color: var(--textColorAccent);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import treeStyles from "./catalog-tree.module.scss";
|
||||
import styles from "./catalog-menu.module.scss";
|
||||
|
||||
import React from "react";
|
||||
import type { TreeItemProps } from "@material-ui/lab";
|
||||
import { TreeItem, TreeView } from "@material-ui/lab";
|
||||
import { Icon } from "../icon";
|
||||
import { StylesProvider } from "@material-ui/core";
|
||||
import { cssNames } from "../../utils";
|
||||
import type { CatalogCategory } from "../../api/catalog-entity";
|
||||
import { observer } from "mobx-react";
|
||||
import { CatalogCategoryLabel } from "./catalog-category-label";
|
||||
import type { IComputedValue } from "mobx";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import filteredCategoriesInjectable from "../../../common/catalog/filtered-categories.injectable";
|
||||
|
||||
export interface CatalogMenuProps {
|
||||
activeTab: string | undefined;
|
||||
onItemClick: (id: string) => void;
|
||||
}
|
||||
|
||||
function getCategoryIcon(category: CatalogCategory) {
|
||||
const { icon } = category.metadata ?? {};
|
||||
|
||||
if (typeof icon === "string") {
|
||||
return Icon.isSvg(icon)
|
||||
? <Icon small svg={icon}/>
|
||||
: <Icon small material={icon}/>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function Item(props: TreeItemProps) {
|
||||
return (
|
||||
<TreeItem classes={treeStyles} {...props}/>
|
||||
);
|
||||
}
|
||||
|
||||
interface Dependencies {
|
||||
filteredCategories: IComputedValue<CatalogCategory[]>;
|
||||
}
|
||||
|
||||
const NonInjectedCatalogMenu = observer(({
|
||||
activeTab,
|
||||
filteredCategories,
|
||||
onItemClick,
|
||||
}: CatalogMenuProps & Dependencies) => (
|
||||
// Overwrite Material UI styles with injectFirst https://material-ui.com/guides/interoperability/#controlling-priority-4
|
||||
<StylesProvider injectFirst>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className={styles.catalog}>Catalog</div>
|
||||
<TreeView
|
||||
defaultExpanded={["catalog"]}
|
||||
defaultCollapseIcon={<Icon material="expand_more" />}
|
||||
defaultExpandIcon={<Icon material="chevron_right" />}
|
||||
selected={activeTab || "browse"}
|
||||
>
|
||||
<Item
|
||||
nodeId="browse"
|
||||
label="Browse"
|
||||
data-testid="*-tab"
|
||||
onClick={() => onItemClick("*")} />
|
||||
<Item
|
||||
nodeId="catalog"
|
||||
label={<div className={styles.parent}>Categories</div>}
|
||||
className={cssNames(styles.bordered)}
|
||||
>
|
||||
{
|
||||
filteredCategories.get()
|
||||
.map(category => (
|
||||
<Item
|
||||
icon={getCategoryIcon(category)}
|
||||
key={category.getId()}
|
||||
nodeId={category.getId()}
|
||||
label={<CatalogCategoryLabel category={category} />}
|
||||
data-testid={`${category.getId()}-tab`}
|
||||
onClick={() => onItemClick(category.getId())} />
|
||||
))
|
||||
}
|
||||
</Item>
|
||||
</TreeView>
|
||||
</div>
|
||||
</StylesProvider>
|
||||
));
|
||||
|
||||
export const CatalogMenu = withInjectables<Dependencies, CatalogMenuProps>(NonInjectedCatalogMenu, {
|
||||
getProps: (di, props) => ({
|
||||
...props,
|
||||
filteredCategories: di.inject(filteredCategoriesInjectable),
|
||||
}),
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable";
|
||||
import { browseCatalogTab } from "../catalog-browse-tab";
|
||||
|
||||
const catalogPreviousActiveTabStorageInjectable = getInjectable({
|
||||
id: "catalog-previous-active-tab-storage",
|
||||
|
||||
instantiate: (di) => {
|
||||
const createStorage = di.inject(createStorageInjectable);
|
||||
|
||||
return createStorage<string | null>(
|
||||
"catalog-previous-active-tab",
|
||||
browseCatalogTab,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default catalogPreviousActiveTabStorageInjectable;
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import { Catalog } from "./catalog";
|
||||
import catalogRouteInjectable from "../../../common/front-end-routing/routes/catalog/catalog-route.injectable";
|
||||
import { routeSpecificComponentInjectionToken } from "../../routes/route-specific-component-injection-token";
|
||||
|
||||
const catalogRouteComponentInjectable = getInjectable({
|
||||
id: "catalog-route-component",
|
||||
|
||||
instantiate: (di) => ({
|
||||
route: di.inject(catalogRouteInjectable),
|
||||
Component: Catalog,
|
||||
}),
|
||||
|
||||
injectionToken: routeSpecificComponentInjectionToken,
|
||||
});
|
||||
|
||||
export default catalogRouteComponentInjectable;
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import { computed } from "mobx";
|
||||
import routePathParametersInjectable from "../../routes/route-path-parameters.injectable";
|
||||
import catalogRouteInjectable from "../../../common/front-end-routing/routes/catalog/catalog-route.injectable";
|
||||
|
||||
const catalogRouteParametersInjectable = getInjectable({
|
||||
id: "catalog-route-parameters",
|
||||
|
||||
instantiate: (di) => {
|
||||
const route = di.inject(catalogRouteInjectable);
|
||||
const pathParameters = di.inject(routePathParametersInjectable, route);
|
||||
|
||||
return {
|
||||
group: computed(() => pathParameters.get().group),
|
||||
kind: computed(() => pathParameters.get().kind),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default catalogRouteParametersInjectable;
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
/* This file overwrites basic Material UI TreeView styles. Can contain only following
|
||||
classnames: root, expanded, selected, content, iconContainer, label, group
|
||||
*/
|
||||
|
||||
.root {
|
||||
color: var(--textColorTertiary);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size)!important;
|
||||
background-color: transparent!important;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 26px;
|
||||
line-height: 1.3;
|
||||
padding: 2px var(--padding) 2px 0;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--sidebarItemHoverBackground);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: white;
|
||||
background-color: var(--blue);
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-left: 0px;
|
||||
|
||||
.iconContainer {
|
||||
margin-left: 28px;
|
||||
margin-top: 2px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
& > *:first-child {
|
||||
background-color: var(--blue);
|
||||
color: white;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
width: 21px;
|
||||
margin-left: 5px;
|
||||
margin-right: 0;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
.Catalog {
|
||||
:global(.TableRow):hover .pinIcon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.entityName {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--padding);
|
||||
padding-bottom: 0;
|
||||
padding-right: 24px; // + reserved space for .pinIcon
|
||||
flex-grow: 2.5!important;
|
||||
|
||||
> span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-left: var(--padding);
|
||||
}
|
||||
|
||||
:global(.HotbarIcon){
|
||||
align-self: center;
|
||||
|
||||
div {
|
||||
/* icons with plain text */
|
||||
font-size: var(--unit);
|
||||
}
|
||||
|
||||
.Icon {
|
||||
/* icons with font-icon */
|
||||
font-size: var(--small-size);
|
||||
}
|
||||
}
|
||||
|
||||
.pinIcon {
|
||||
--color-active: var(--textColorAccent);
|
||||
|
||||
transition: none;
|
||||
opacity: 0;
|
||||
margin-left: var(--padding);
|
||||
width: calc(var(--unit) * 2);
|
||||
|
||||
&:hover {
|
||||
/* Drop styles defined for <Icon/> */
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sourceCell {
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.statusCell {
|
||||
max-width: 100px;
|
||||
|
||||
:global {
|
||||
.connected, .available {
|
||||
color: var(--colorSuccess);
|
||||
}
|
||||
|
||||
.disconnected, .deleting, .unavailable {
|
||||
color: var(--halfGray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.labelsCell {
|
||||
overflow-x: scroll;
|
||||
text-overflow: unset;
|
||||
}
|
||||
|
||||
.labelsCell::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.badge {
|
||||
overflow: unset;
|
||||
text-overflow: unset;
|
||||
max-width: unset;
|
||||
}
|
||||
|
||||
.badge:hover {
|
||||
color: var(--textColorAccent);
|
||||
}
|
||||
|
||||
.badge:not(:first-child) {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
.catalogIcon {
|
||||
font-size: 10px;
|
||||
-webkit-font-smoothing: auto;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
@apply flex flex-grow flex-col;
|
||||
}
|
||||
|
||||
.tab {
|
||||
@apply px-8 py-4;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background-color: var(--sidebarItemHoverBackground);
|
||||
--color-active: var(--textColorTertiary);
|
||||
}
|
||||
|
||||
.tab::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activeTab, .activeTab:hover {
|
||||
background-color: var(--blue);
|
||||
--color-active: white;
|
||||
}
|
||||
|
||||
.catalogAvatar {
|
||||
font-size: 1.2ch;
|
||||
}
|
||||
|
||||
.views {
|
||||
padding: calc(var(--padding) * 2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Catalog } from "./catalog";
|
||||
import type { CatalogEntityActionContext, CatalogEntityData } from "../../../common/catalog";
|
||||
import { CatalogEntity } from "../../../common/catalog";
|
||||
import type { CatalogEntityOnBeforeRun, CatalogEntityRegistry } from "../../api/catalog/entity/registry";
|
||||
import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.store";
|
||||
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
|
||||
import type { DiContainer } from "@ogre-tools/injectable";
|
||||
import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable";
|
||||
import catalogEntityRegistryInjectable from "../../api/catalog/entity/registry.injectable";
|
||||
import type { DiRender } from "../test-utils/renderFor";
|
||||
import { renderFor } from "../test-utils/renderFor";
|
||||
import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
|
||||
import getConfigurationFileModelInjectable from "../../../common/get-configuration-file-model/get-configuration-file-model.injectable";
|
||||
import type { AppEvent } from "../../../common/app-event-bus/event-bus";
|
||||
import appEventBusInjectable from "../../../common/app-event-bus/app-event-bus.injectable";
|
||||
import { computed } from "mobx";
|
||||
import broadcastMessageInjectable from "../../../common/ipc/broadcast-message.injectable";
|
||||
import type { AsyncFnMock } from "@async-fn/jest";
|
||||
import asyncFn from "@async-fn/jest";
|
||||
import { flushPromises } from "../../../common/test-utils/flush-promises";
|
||||
import userStoreInjectable from "../../../common/user-store/user-store.injectable";
|
||||
import releaseChannelInjectable from "../../../common/vars/release-channel.injectable";
|
||||
import defaultUpdateChannelInjectable from "../../../features/application-update/common/selected-update-channel/default-update-channel.injectable";
|
||||
import currentlyInClusterFrameInjectable from "../../routes/currently-in-cluster-frame.injectable";
|
||||
|
||||
class MockCatalogEntity extends CatalogEntity {
|
||||
public apiVersion = "api";
|
||||
public kind = "kind";
|
||||
|
||||
constructor(data: CatalogEntityData, public onRun: (context: CatalogEntityActionContext) => void | Promise<void>) {
|
||||
super(data);
|
||||
}
|
||||
}
|
||||
|
||||
function createMockCatalogEntity(onRun: (context: CatalogEntityActionContext) => void | Promise<void>) {
|
||||
return new MockCatalogEntity({
|
||||
metadata: {
|
||||
uid: "a_catalogEntity_uid",
|
||||
name: "a catalog entity",
|
||||
labels: {
|
||||
test: "label",
|
||||
},
|
||||
},
|
||||
status: {
|
||||
phase: "",
|
||||
},
|
||||
spec: {},
|
||||
}, onRun);
|
||||
}
|
||||
|
||||
describe("<Catalog />", () => {
|
||||
let di: DiContainer;
|
||||
let catalogEntityStore: CatalogEntityStore;
|
||||
let catalogEntityRegistry: CatalogEntityRegistry;
|
||||
let appEventListener: jest.MockedFunction<(event: AppEvent) => void>;
|
||||
let onRun: jest.MockedFunction<(context: CatalogEntityActionContext) => void | Promise<void>>;
|
||||
let catalogEntityItem: MockCatalogEntity;
|
||||
let render: DiRender;
|
||||
|
||||
beforeEach(async () => {
|
||||
di = getDiForUnitTesting({ doGeneralOverrides: true });
|
||||
|
||||
di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data");
|
||||
di.override(currentlyInClusterFrameInjectable, () => false);
|
||||
di.override(broadcastMessageInjectable, () => async () => {});
|
||||
|
||||
di.permitSideEffects(getConfigurationFileModelInjectable);
|
||||
|
||||
render = renderFor(di);
|
||||
onRun = jest.fn();
|
||||
catalogEntityItem = createMockCatalogEntity(onRun);
|
||||
catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable);
|
||||
|
||||
appEventListener = jest.fn();
|
||||
di.inject(appEventBusInjectable).addListener(appEventListener);
|
||||
|
||||
catalogEntityStore = di.inject(catalogEntityStoreInjectable);
|
||||
Object.assign(catalogEntityStore, {
|
||||
selectedItem: computed(() => catalogEntityItem),
|
||||
});
|
||||
|
||||
di.override(releaseChannelInjectable, () => ({
|
||||
get: () => "latest" as const,
|
||||
init: async () => {},
|
||||
}));
|
||||
await di.inject(defaultUpdateChannelInjectable).init();
|
||||
di.inject(userStoreInjectable).load();
|
||||
});
|
||||
|
||||
describe("can use catalogEntityRegistry.addOnBeforeRun to add hooks for catalog entities", () => {
|
||||
let onBeforeRunMock: AsyncFnMock<CatalogEntityOnBeforeRun>;
|
||||
|
||||
beforeEach(() => {
|
||||
onBeforeRunMock = asyncFn();
|
||||
|
||||
catalogEntityRegistry.addOnBeforeRun(onBeforeRunMock);
|
||||
|
||||
render(<Catalog />);
|
||||
|
||||
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
|
||||
});
|
||||
|
||||
it("calls on before run event", () => {
|
||||
const target = onBeforeRunMock.mock.calls[0][0].target;
|
||||
|
||||
const actual = { id: target.getId(), name: target.getName() };
|
||||
|
||||
expect(actual).toEqual({
|
||||
id: "a_catalogEntity_uid",
|
||||
name: "a catalog entity",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call onRun yet", () => {
|
||||
expect(onRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("when before run event resolves, calls onRun", async () => {
|
||||
await onBeforeRunMock.resolve();
|
||||
|
||||
expect(onRun).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("onBeforeRun prevents event => onRun wont be triggered", async () => {
|
||||
const onBeforeRunMock = jest.fn((event) => event.preventDefault());
|
||||
|
||||
catalogEntityRegistry.addOnBeforeRun(onBeforeRunMock);
|
||||
|
||||
render(<Catalog />);
|
||||
|
||||
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(onRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("addOnBeforeRun throw an exception => onRun will be triggered", async () => {
|
||||
const onBeforeRunMock = jest.fn(() => {
|
||||
throw new Error("some error");
|
||||
});
|
||||
|
||||
catalogEntityRegistry.addOnBeforeRun(onBeforeRunMock);
|
||||
|
||||
render(<Catalog />);
|
||||
|
||||
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(onRun).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("addOnRunHook return a promise and does not prevent run event => onRun()", (done) => {
|
||||
onRun.mockImplementation(() => done());
|
||||
|
||||
catalogEntityRegistry.addOnBeforeRun(
|
||||
async () => {
|
||||
// no op
|
||||
},
|
||||
);
|
||||
|
||||
render(<Catalog />);
|
||||
|
||||
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
|
||||
});
|
||||
|
||||
it("addOnRunHook return a promise and prevents event wont be triggered", async () => {
|
||||
const onBeforeRunMock = asyncFn();
|
||||
|
||||
catalogEntityRegistry.addOnBeforeRun(onBeforeRunMock);
|
||||
|
||||
render(<Catalog />);
|
||||
|
||||
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
|
||||
|
||||
onBeforeRunMock.mock.calls[0][0].preventDefault();
|
||||
|
||||
await onBeforeRunMock.resolve();
|
||||
|
||||
expect(onRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("addOnRunHook return a promise and reject => onRun will be triggered", async () => {
|
||||
const onBeforeRunMock = asyncFn();
|
||||
|
||||
catalogEntityRegistry.addOnBeforeRun(onBeforeRunMock);
|
||||
|
||||
render(<Catalog />);
|
||||
|
||||
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
|
||||
|
||||
await onBeforeRunMock.reject();
|
||||
|
||||
expect(onRun).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits catalog open AppEvent", () => {
|
||||
render(<Catalog />);
|
||||
|
||||
expect(appEventListener).toHaveBeenCalledWith( {
|
||||
action: "open",
|
||||
name: "catalog",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits catalog change AppEvent when changing the category", () => {
|
||||
render(<Catalog />);
|
||||
|
||||
userEvent.click(screen.getByText("Web Links"));
|
||||
|
||||
expect(appEventListener).toHaveBeenCalledWith({
|
||||
action: "change-category",
|
||||
name: "catalog",
|
||||
params: {
|
||||
category: "Web Links",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import styles from "./catalog.module.scss";
|
||||
|
||||
import React from "react";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import { ItemListLayout } from "../item-object-list";
|
||||
import type { IComputedValue } from "mobx";
|
||||
import { action, computed, makeObservable, observable, reaction, runInAction, when } from "mobx";
|
||||
import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.store";
|
||||
import { MenuItem, MenuActions } from "../menu";
|
||||
import type { CatalogEntityContextMenu } from "../../api/catalog-entity";
|
||||
import type { CatalogCategory, CatalogCategoryRegistry, CatalogEntity } from "../../../common/catalog";
|
||||
import { CatalogAddButton } from "./catalog-add-button";
|
||||
import type { ShowNotification } from "../notifications";
|
||||
import { MainLayout } from "../layout/main-layout";
|
||||
import type { StorageLayer } from "../../utils";
|
||||
import { prevDefault } from "../../utils";
|
||||
import { CatalogEntityDetails } from "./entity-details/view";
|
||||
import { CatalogMenu } from "./catalog-menu";
|
||||
import { RenderDelay } from "../render-delay/render-delay";
|
||||
import { Icon } from "../icon";
|
||||
import { HotbarToggleMenuItem } from "./hotbar-toggle-menu-item";
|
||||
import { Avatar } from "../avatar";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import catalogPreviousActiveTabStorageInjectable from "./catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable";
|
||||
import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable";
|
||||
import type { GetCategoryColumnsParams, CategoryColumns } from "./columns/get.injectable";
|
||||
import getCategoryColumnsInjectable from "./columns/get.injectable";
|
||||
import type { RegisteredCustomCategoryViewDecl } from "./custom-views.injectable";
|
||||
import customCategoryViewsInjectable from "./custom-views.injectable";
|
||||
import type { CustomCategoryViewComponents } from "./custom-views";
|
||||
import type { NavigateToCatalog } from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";
|
||||
import navigateToCatalogInjectable from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";
|
||||
import catalogRouteParametersInjectable from "./catalog-route-parameters.injectable";
|
||||
import { browseCatalogTab } from "./catalog-browse-tab";
|
||||
import hotbarStoreInjectable from "../../../common/hotbars/store.injectable";
|
||||
import type { HotbarStore } from "../../../common/hotbars/store";
|
||||
import type { VisitEntityContextMenu } from "../../../common/catalog/visit-entity-context-menu.injectable";
|
||||
import catalogCategoryRegistryInjectable from "../../../common/catalog/category-registry.injectable";
|
||||
import visitEntityContextMenuInjectable from "../../../common/catalog/visit-entity-context-menu.injectable";
|
||||
import type { Navigate } from "../../navigation/navigate.injectable";
|
||||
import navigateInjectable from "../../navigation/navigate.injectable";
|
||||
import type { NormalizeCatalogEntityContextMenu } from "../../catalog/normalize-menu-item.injectable";
|
||||
import normalizeCatalogEntityContextMenuInjectable from "../../catalog/normalize-menu-item.injectable";
|
||||
import type { EmitAppEvent } from "../../../common/app-event-bus/emit-event.injectable";
|
||||
import emitAppEventInjectable from "../../../common/app-event-bus/emit-event.injectable";
|
||||
import type { Logger } from "../../../common/logger";
|
||||
import loggerInjectable from "../../../common/logger.injectable";
|
||||
import showErrorNotificationInjectable from "../notifications/show-error-notification.injectable";
|
||||
|
||||
interface Dependencies {
|
||||
catalogPreviousActiveTabStorage: StorageLayer<string | null>;
|
||||
catalogEntityStore: CatalogEntityStore;
|
||||
getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns;
|
||||
customCategoryViews: IComputedValue<Map<string, Map<string, RegisteredCustomCategoryViewDecl>>>;
|
||||
emitEvent: EmitAppEvent;
|
||||
routeParameters: {
|
||||
group: IComputedValue<string>;
|
||||
kind: IComputedValue<string>;
|
||||
};
|
||||
navigateToCatalog: NavigateToCatalog;
|
||||
hotbarStore: HotbarStore;
|
||||
catalogCategoryRegistry: CatalogCategoryRegistry;
|
||||
visitEntityContextMenu: VisitEntityContextMenu;
|
||||
navigate: Navigate;
|
||||
normalizeMenuItem: NormalizeCatalogEntityContextMenu;
|
||||
showErrorNotification: ShowNotification;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
@observer
|
||||
class NonInjectedCatalog extends React.Component<Dependencies> {
|
||||
private readonly menuItems = observable.array<CatalogEntityContextMenu>();
|
||||
@observable activeTab: string | undefined = undefined;
|
||||
|
||||
constructor(props: Dependencies) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
@computed
|
||||
get routeActiveTab(): string {
|
||||
const { routeParameters: { group, kind }, catalogPreviousActiveTabStorage } = this.props;
|
||||
|
||||
const dereferencedGroup = group.get();
|
||||
const dereferencedKind = kind.get();
|
||||
|
||||
if (dereferencedGroup && dereferencedKind) {
|
||||
return `${dereferencedGroup}/${dereferencedKind}`;
|
||||
}
|
||||
|
||||
return catalogPreviousActiveTabStorage.get() || browseCatalogTab;
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const {
|
||||
catalogEntityStore,
|
||||
catalogPreviousActiveTabStorage,
|
||||
catalogCategoryRegistry,
|
||||
logger,
|
||||
showErrorNotification,
|
||||
} = this.props;
|
||||
|
||||
disposeOnUnmount(this, [
|
||||
catalogEntityStore.watch(),
|
||||
reaction(() => this.routeActiveTab, async (routeTab) => {
|
||||
catalogPreviousActiveTabStorage.set(this.routeActiveTab);
|
||||
|
||||
try {
|
||||
if (routeTab !== browseCatalogTab) {
|
||||
// we need to wait because extensions might take a while to load
|
||||
await when(() => Boolean(catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab)), { timeout: 5_000 });
|
||||
}
|
||||
|
||||
const item = catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab);
|
||||
|
||||
runInAction(() => {
|
||||
this.activeTab = routeTab;
|
||||
catalogEntityStore.activeCategory.set(item);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn("Failed to find route tab", error);
|
||||
showErrorNotification((
|
||||
<p>
|
||||
{"Unknown category: "}
|
||||
{routeTab}
|
||||
</p>
|
||||
));
|
||||
}
|
||||
}, { fireImmediately: true }),
|
||||
// If active category is filtered out, automatically switch to the first category
|
||||
reaction(() => catalogCategoryRegistry.filteredItems, () => {
|
||||
if (!catalogCategoryRegistry.filteredItems.find(item => item.getId() === catalogEntityStore.activeCategory.get()?.getId())) {
|
||||
const item = catalogCategoryRegistry.filteredItems[0];
|
||||
|
||||
runInAction(() => {
|
||||
if (item) {
|
||||
this.activeTab = item.getId();
|
||||
this.props.catalogEntityStore.activeCategory.set(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
this.props.emitEvent({
|
||||
name: "catalog",
|
||||
action: "open",
|
||||
});
|
||||
}
|
||||
|
||||
addToHotbar(entity: CatalogEntity): void {
|
||||
this.props.hotbarStore.addToHotbar(entity);
|
||||
}
|
||||
|
||||
removeFromHotbar(entity: CatalogEntity): void {
|
||||
this.props.hotbarStore.removeFromHotbar(entity.getId());
|
||||
}
|
||||
|
||||
onDetails = (entity: CatalogEntity) => {
|
||||
if (this.props.catalogEntityStore.selectedItemId.get()) {
|
||||
this.props.catalogEntityStore.selectedItemId.set(undefined);
|
||||
} else {
|
||||
this.props.catalogEntityStore.onRun(entity);
|
||||
}
|
||||
};
|
||||
|
||||
get categories() {
|
||||
return this.props.catalogCategoryRegistry.items;
|
||||
}
|
||||
|
||||
onTabChange = action((tabId: string | null) => {
|
||||
const activeCategory = this.categories.find(category => category.getId() === tabId);
|
||||
|
||||
this.props.emitEvent({
|
||||
name: "catalog",
|
||||
action: "change-category",
|
||||
params: {
|
||||
category: activeCategory ? activeCategory.getName() : "Browse",
|
||||
},
|
||||
});
|
||||
|
||||
if (activeCategory) {
|
||||
this.props.catalogPreviousActiveTabStorage.set(`${activeCategory.spec.group}/${activeCategory.spec.names.kind}`);
|
||||
this.props.navigateToCatalog({ group: activeCategory.spec.group, kind: activeCategory.spec.names.kind });
|
||||
} else {
|
||||
this.props.catalogPreviousActiveTabStorage.set(null);
|
||||
this.props.navigateToCatalog({ group: browseCatalogTab });
|
||||
}
|
||||
});
|
||||
|
||||
renderItemMenu = (entity: CatalogEntity) => {
|
||||
const onOpen = () => {
|
||||
this.menuItems.clear();
|
||||
this.props.visitEntityContextMenu(entity, {
|
||||
menuItems: this.menuItems,
|
||||
navigate: this.props.navigate,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuActions
|
||||
id={`menu-actions-for-catalog-for-${entity.getId()}`}
|
||||
data-testid={`menu-actions-for-catalog-for-${entity.getId()}`}
|
||||
onOpen={onOpen}
|
||||
>
|
||||
<MenuItem
|
||||
key="open-details"
|
||||
data-testid={`open-details-menu-item-for-${entity.getId()}`}
|
||||
onClick={() => this.props.catalogEntityStore.selectedItemId.set(entity.getId())}
|
||||
>
|
||||
View Details
|
||||
</MenuItem>
|
||||
{
|
||||
this.menuItems
|
||||
.map(this.props.normalizeMenuItem)
|
||||
.map((menuItem, index) => (
|
||||
<MenuItem key={index} onClick={menuItem.onClick}>
|
||||
{menuItem.title}
|
||||
</MenuItem>
|
||||
))
|
||||
}
|
||||
<HotbarToggleMenuItem
|
||||
key="hotbar-toggle"
|
||||
entity={entity}
|
||||
addContent="Add to Hotbar"
|
||||
removeContent="Remove from Hotbar"
|
||||
/>
|
||||
</MenuActions>
|
||||
);
|
||||
};
|
||||
|
||||
renderName(entity: CatalogEntity) {
|
||||
const isItemInHotbar = this.props.hotbarStore.isAddedToActive(entity);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Avatar
|
||||
title={entity.getName()}
|
||||
colorHash={`${entity.getName()}-${entity.getSource()}`}
|
||||
src={entity.spec.icon?.src}
|
||||
background={entity.spec.icon?.background}
|
||||
className={styles.catalogAvatar}
|
||||
size={24}
|
||||
>
|
||||
{entity.spec.icon?.material && <Icon material={entity.spec.icon?.material} small/>}
|
||||
</Avatar>
|
||||
<span>{entity.getName()}</span>
|
||||
<Icon
|
||||
small
|
||||
className={styles.pinIcon}
|
||||
svg={isItemInHotbar ? "push_off" : "push_pin"}
|
||||
tooltip={isItemInHotbar ? "Remove from Hotbar" : "Add to Hotbar"}
|
||||
onClick={prevDefault(() => isItemInHotbar ? this.removeFromHotbar(entity) : this.addToHotbar(entity))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderViews = (activeCategory: CatalogCategory | undefined) => {
|
||||
if (!activeCategory) {
|
||||
return this.renderList(undefined);
|
||||
}
|
||||
|
||||
const customViews = this.props.customCategoryViews.get()
|
||||
.get(activeCategory.spec.group)
|
||||
?.get(activeCategory.spec.names.kind);
|
||||
const renderView = ({ View }: CustomCategoryViewComponents, index: number) => (
|
||||
<View
|
||||
key={index}
|
||||
category={activeCategory}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{customViews?.before.map(renderView)}
|
||||
{this.renderList(activeCategory)}
|
||||
{customViews?.after.map(renderView)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
renderList(activeCategory: CatalogCategory | undefined) {
|
||||
const { catalogEntityStore, getCategoryColumns } = this.props;
|
||||
const tableId = activeCategory
|
||||
? `catalog-items-${activeCategory.metadata.name.replace(" ", "")}`
|
||||
: "catalog-items";
|
||||
|
||||
if (this.activeTab === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ItemListLayout<CatalogEntity, false>
|
||||
className={styles.Catalog}
|
||||
tableId={tableId}
|
||||
renderHeaderTitle={activeCategory?.metadata.name ?? "Browse All"}
|
||||
isSelectable={false}
|
||||
isConfigurable={true}
|
||||
preloadStores={false}
|
||||
store={catalogEntityStore}
|
||||
getItems={() => catalogEntityStore.entities.get()}
|
||||
customizeTableRowProps={entity => ({
|
||||
disabled: !entity.isEnabled(),
|
||||
})}
|
||||
{...getCategoryColumns({ activeCategory })}
|
||||
onDetails={this.onDetails}
|
||||
renderItemMenu={this.renderItemMenu}
|
||||
data-testid={`catalog-list-for-${activeCategory?.metadata.name ?? "browse-all"}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const activeCategory = this.props.catalogEntityStore.activeCategory.get();
|
||||
const selectedItem = this.props.catalogEntityStore.selectedItem.get();
|
||||
|
||||
return (
|
||||
<MainLayout
|
||||
sidebar={(
|
||||
<CatalogMenu
|
||||
activeTab={this.activeTab}
|
||||
onItemClick={this.onTabChange}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className={styles.views}>
|
||||
{this.renderViews(activeCategory)}
|
||||
</div>
|
||||
{
|
||||
selectedItem
|
||||
? (
|
||||
<CatalogEntityDetails
|
||||
entity={selectedItem}
|
||||
hideDetails={() => this.props.catalogEntityStore.selectedItemId.set(undefined)}
|
||||
onRun={() => this.props.catalogEntityStore.onRun(selectedItem)}
|
||||
/>
|
||||
)
|
||||
: activeCategory
|
||||
? (
|
||||
<RenderDelay>
|
||||
<CatalogAddButton category={activeCategory} />
|
||||
</RenderDelay>
|
||||
)
|
||||
: null
|
||||
}
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const Catalog = withInjectables<Dependencies>(NonInjectedCatalog, {
|
||||
getProps: (di, props) => ({
|
||||
...props,
|
||||
catalogEntityStore: di.inject(catalogEntityStoreInjectable),
|
||||
catalogPreviousActiveTabStorage: di.inject(catalogPreviousActiveTabStorageInjectable),
|
||||
getCategoryColumns: di.inject(getCategoryColumnsInjectable),
|
||||
customCategoryViews: di.inject(customCategoryViewsInjectable),
|
||||
routeParameters: di.inject(catalogRouteParametersInjectable),
|
||||
navigateToCatalog: di.inject(navigateToCatalogInjectable),
|
||||
emitEvent: di.inject(emitAppEventInjectable),
|
||||
hotbarStore: di.inject(hotbarStoreInjectable),
|
||||
catalogCategoryRegistry: di.inject(catalogCategoryRegistryInjectable),
|
||||
visitEntityContextMenu: di.inject(visitEntityContextMenuInjectable),
|
||||
navigate: di.inject(navigateInjectable),
|
||||
normalizeMenuItem: di.inject(normalizeCatalogEntityContextMenuInjectable),
|
||||
logger: di.inject(loggerInjectable),
|
||||
showErrorNotification: di.inject(showErrorNotificationInjectable),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns";
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import namedCategoryColumnInjectable from "./named-category.injectable";
|
||||
import defaultCategoryColumnsInjectable from "./default-category.injectable";
|
||||
|
||||
const defaultBrowseAllColumns: RegisteredAdditionalCategoryColumn[] = [
|
||||
{
|
||||
id: "kind",
|
||||
priority: 5,
|
||||
renderCell: entity => entity.kind,
|
||||
titleProps: {
|
||||
id: "kind",
|
||||
sortBy: "kind",
|
||||
title: "Kind",
|
||||
},
|
||||
sortCallback: entity => entity.kind,
|
||||
},
|
||||
];
|
||||
|
||||
const browseAllColumnsInjectable = getInjectable({
|
||||
id: "browse-all-columns",
|
||||
instantiate: (di) => [
|
||||
...defaultBrowseAllColumns,
|
||||
di.inject(namedCategoryColumnInjectable),
|
||||
...di.inject(defaultCategoryColumnsInjectable),
|
||||
],
|
||||
});
|
||||
|
||||
export default browseAllColumnsInjectable;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import { getInjectionToken } from "@ogre-tools/injectable";
|
||||
import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns";
|
||||
|
||||
export interface CustomCatalogCategoryRegistration {
|
||||
kind: string;
|
||||
group: string;
|
||||
registration: RegisteredAdditionalCategoryColumn;
|
||||
}
|
||||
|
||||
export const customCatalogCategoryColumnInjectionToken = getInjectionToken<CustomCatalogCategoryRegistration>({
|
||||
id: "custom-catalog-category-column-token",
|
||||
});
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import styles from "../catalog.module.scss";
|
||||
import React from "react";
|
||||
import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns";
|
||||
import { KubeObject } from "../../../../common/k8s-api/kube-object";
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import getLabelBadgesInjectable from "../get-label-badges.injectable";
|
||||
|
||||
const defaultCategoryColumnsInjectable = getInjectable({
|
||||
id: "default-category-columns",
|
||||
instantiate: (di): RegisteredAdditionalCategoryColumn[] => {
|
||||
const getLabelBadges = di.inject(getLabelBadgesInjectable);
|
||||
|
||||
return [
|
||||
{
|
||||
id: "source",
|
||||
priority: 10,
|
||||
renderCell: entity => entity.getSource(),
|
||||
titleProps: {
|
||||
title: "Source",
|
||||
className: styles.sourceCell,
|
||||
id: "source",
|
||||
sortBy: "source",
|
||||
},
|
||||
sortCallback: entity => entity.getSource(),
|
||||
searchFilter: entity => `source=${entity.getSource()}`,
|
||||
},
|
||||
{
|
||||
id: "labels",
|
||||
priority: 20,
|
||||
renderCell: getLabelBadges,
|
||||
titleProps: {
|
||||
id: "labels",
|
||||
title: "Labels",
|
||||
className: `${styles.labelsCell} scrollable`,
|
||||
},
|
||||
searchFilter: entity => KubeObject.stringifyLabels(entity.metadata.labels),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
priority: 30,
|
||||
renderCell: entity => (
|
||||
<span key="phase" className={entity.status.phase}>
|
||||
{entity.status.phase}
|
||||
</span>
|
||||
),
|
||||
titleProps: {
|
||||
title: "Status",
|
||||
className: styles.statusCell,
|
||||
id: "status",
|
||||
sortBy: "status",
|
||||
},
|
||||
searchFilter: entity => entity.status.phase,
|
||||
sortCallback: entity => entity.status.phase,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export default defaultCategoryColumnsInjectable;
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import { extensionRegistratorInjectionToken } from "../../../../extensions/extension-loader/extension-registrator-injection-token";
|
||||
import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
|
||||
import type { AdditionalCategoryColumnRegistration } from "../custom-category-columns";
|
||||
import { customCatalogCategoryColumnInjectionToken } from "./custom-token";
|
||||
|
||||
const customCategoryColumnsRegistratorInjectable = getInjectable({
|
||||
id: "custom-category-columns-registrator",
|
||||
instantiate: () => (ext) => {
|
||||
const extension = ext as LensRendererExtension;
|
||||
|
||||
return extension.additionalCategoryColumns.map(getInjectableForColumnRegistrationFor(extension));
|
||||
},
|
||||
injectionToken: extensionRegistratorInjectionToken,
|
||||
});
|
||||
|
||||
export default customCategoryColumnsRegistratorInjectable;
|
||||
|
||||
const getInjectableForColumnRegistrationFor = (extension: LensRendererExtension) => ({
|
||||
group,
|
||||
id,
|
||||
kind,
|
||||
renderCell,
|
||||
titleProps,
|
||||
priority = 50,
|
||||
searchFilter,
|
||||
sortCallback,
|
||||
}: AdditionalCategoryColumnRegistration) => {
|
||||
return getInjectable({
|
||||
id: `${extension.manifest.name}:${group}/${kind}:${id}`,
|
||||
instantiate: () => ({
|
||||
group,
|
||||
kind,
|
||||
registration: {
|
||||
renderCell,
|
||||
priority,
|
||||
id,
|
||||
titleProps: {
|
||||
id,
|
||||
...titleProps,
|
||||
sortBy: sortCallback
|
||||
? id
|
||||
: undefined,
|
||||
},
|
||||
searchFilter,
|
||||
sortCallback,
|
||||
},
|
||||
}),
|
||||
injectionToken: customCatalogCategoryColumnInjectionToken,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import { orderBy } from "lodash";
|
||||
import type { CatalogCategory, CatalogEntity } from "../../../../common/catalog";
|
||||
import type { ItemListLayoutProps } from "../../item-object-list";
|
||||
import browseAllColumnsInjectable from "./browse-all.injectable";
|
||||
import getColumnsForCategoryInjectable from "./specific-category.injectable";
|
||||
|
||||
export interface GetCategoryColumnsParams {
|
||||
activeCategory: CatalogCategory | null | undefined;
|
||||
}
|
||||
|
||||
export type CategoryColumns = Required<Pick<ItemListLayoutProps<CatalogEntity>, "sortingCallbacks" | "searchFilters" | "renderTableContents" | "renderTableHeader">>;
|
||||
export type GetCategoryColumns = (params: GetCategoryColumnsParams) => CategoryColumns;
|
||||
|
||||
const getCategoryColumnsInjectable = getInjectable({
|
||||
id: "get-category-columns",
|
||||
|
||||
instantiate: (di): GetCategoryColumns => {
|
||||
const getColumnsForCategory = di.inject(getColumnsForCategoryInjectable);
|
||||
const browseAllColumns = di.inject(browseAllColumnsInjectable);
|
||||
|
||||
return ({ activeCategory }) => {
|
||||
const allRegistrations = orderBy(
|
||||
activeCategory
|
||||
? getColumnsForCategory(activeCategory)
|
||||
: browseAllColumns,
|
||||
"priority",
|
||||
"asc",
|
||||
);
|
||||
|
||||
const sortingCallbacks: CategoryColumns["sortingCallbacks"] = {};
|
||||
const searchFilters: CategoryColumns["searchFilters"] = [];
|
||||
const renderTableHeader: CategoryColumns["renderTableHeader"] = [];
|
||||
const tableRowRenderers: ((entity: CatalogEntity) => React.ReactNode)[] = [];
|
||||
|
||||
for (const registration of allRegistrations) {
|
||||
if (registration.sortCallback) {
|
||||
sortingCallbacks[registration.id] = registration.sortCallback;
|
||||
}
|
||||
|
||||
if (registration.searchFilter) {
|
||||
searchFilters.push(registration.searchFilter);
|
||||
}
|
||||
|
||||
tableRowRenderers.push(registration.renderCell);
|
||||
renderTableHeader.push({
|
||||
id: registration.id,
|
||||
...registration.titleProps,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sortingCallbacks,
|
||||
renderTableHeader,
|
||||
renderTableContents: entity => tableRowRenderers.map(fn => fn(entity)),
|
||||
searchFilters,
|
||||
};
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default getCategoryColumnsInjectable;
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import React from "react";
|
||||
import type { KubernetesCluster } from "../../../../common/catalog-entities";
|
||||
import { customCatalogCategoryColumnInjectionToken } from "./custom-token";
|
||||
|
||||
const kubernetesApiVersionColumnInjectable = getInjectable({
|
||||
id: "kubernetes-api-version-column",
|
||||
instantiate: () => ({
|
||||
group: "entity.k8slens.dev",
|
||||
kind: "KubernetesCluster",
|
||||
registration: {
|
||||
id: "version",
|
||||
priority: 30,
|
||||
renderCell: entity => {
|
||||
const k8sVersion = (entity as KubernetesCluster).metadata.kubeVersion;
|
||||
|
||||
return (
|
||||
<span key="version">
|
||||
{k8sVersion === "unknown" ? "" : k8sVersion}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
titleProps: {
|
||||
title: "Version",
|
||||
},
|
||||
},
|
||||
}),
|
||||
injectionToken: customCatalogCategoryColumnInjectionToken,
|
||||
});
|
||||
|
||||
export default kubernetesApiVersionColumnInjectable;
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import React from "react";
|
||||
import type { KubernetesCluster } from "../../../../common/catalog-entities";
|
||||
import { customCatalogCategoryColumnInjectionToken } from "./custom-token";
|
||||
|
||||
const kubernetesDistributionColumnInjectable = getInjectable({
|
||||
id: "kubernetes-distribution-column",
|
||||
instantiate: () => ({
|
||||
group: "entity.k8slens.dev",
|
||||
kind: "KubernetesCluster",
|
||||
registration: {
|
||||
id: "distro",
|
||||
priority: 30,
|
||||
renderCell: entity => {
|
||||
const k8sDistro = (entity as KubernetesCluster).metadata.distro;
|
||||
|
||||
return (
|
||||
<span key="distro">
|
||||
{k8sDistro === "unknown" ? "" : k8sDistro}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
titleProps: {
|
||||
title: "Distro",
|
||||
},
|
||||
},
|
||||
}),
|
||||
injectionToken: customCatalogCategoryColumnInjectionToken,
|
||||
});
|
||||
|
||||
export default kubernetesDistributionColumnInjectable;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import styles from "../catalog.module.scss";
|
||||
import type { CatalogEntity } from "../../../../common/catalog";
|
||||
import { prevDefault } from "../../../utils";
|
||||
import { Avatar } from "../../avatar";
|
||||
import { Icon } from "../../icon";
|
||||
import React from "react";
|
||||
import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns";
|
||||
import hotbarStoreInjectable from "../../../../common/hotbars/store.injectable";
|
||||
import type { HotbarStore } from "../../../../common/hotbars/store";
|
||||
|
||||
const renderEntityName = (hotbarStore: HotbarStore) => (entity: CatalogEntity) => {
|
||||
const isItemInHotbar = hotbarStore.isAddedToActive(entity);
|
||||
const onClick = prevDefault(
|
||||
isItemInHotbar
|
||||
? () => hotbarStore.removeFromHotbar(entity.getId())
|
||||
: () => hotbarStore.addToHotbar(entity),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Avatar
|
||||
title={entity.getName()}
|
||||
colorHash={`${entity.getName()}-${entity.getSource()}`}
|
||||
src={entity.spec.icon?.src}
|
||||
background={entity.spec.icon?.background}
|
||||
className={styles.catalogAvatar}
|
||||
size={24}
|
||||
>
|
||||
{entity.spec.icon?.material && <Icon material={entity.spec.icon?.material} small/>}
|
||||
</Avatar>
|
||||
<span>{entity.getName()}</span>
|
||||
<Icon
|
||||
small
|
||||
className={styles.pinIcon}
|
||||
svg={isItemInHotbar ? "push_off" : "push_pin"}
|
||||
tooltip={isItemInHotbar ? "Remove from Hotbar" : "Add to Hotbar"}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const namedCategoryColumnInjectable = getInjectable({
|
||||
id: "name-category-column",
|
||||
instantiate: (di): RegisteredAdditionalCategoryColumn => ({
|
||||
id: "name",
|
||||
priority: 0,
|
||||
renderCell: renderEntityName(di.inject(hotbarStoreInjectable)),
|
||||
titleProps: {
|
||||
title: "Name",
|
||||
className: styles.entityName,
|
||||
id: "name",
|
||||
sortBy: "name",
|
||||
},
|
||||
searchFilter: (entity) => entity.getName(),
|
||||
sortCallback: (entity) => `name=${entity.getName()}`,
|
||||
}),
|
||||
});
|
||||
|
||||
export default namedCategoryColumnInjectable;
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import type { CatalogCategory } from "../../../api/catalog-entity";
|
||||
import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns";
|
||||
import categoryColumnsInjectable from "../custom-category-columns.injectable";
|
||||
import defaultCategoryColumnsInjectable from "./default-category.injectable";
|
||||
import namedCategoryColumnInjectable from "./named-category.injectable";
|
||||
|
||||
export type GetColumnsForCategory = (activeCategory: CatalogCategory) => RegisteredAdditionalCategoryColumn[];
|
||||
|
||||
const getColumnsForCategoryInjectable = getInjectable({
|
||||
id: "get-columns-for-category",
|
||||
instantiate: (di): GetColumnsForCategory => {
|
||||
const extensionColumns = di.inject(categoryColumnsInjectable);
|
||||
const defaultCategoryColumns = di.inject(defaultCategoryColumnsInjectable);
|
||||
const nameCategoryColumn = di.inject(namedCategoryColumnInjectable);
|
||||
|
||||
return (activeCategory) => {
|
||||
const fromExtensions = (
|
||||
extensionColumns
|
||||
.get()
|
||||
.get(activeCategory.spec.group)
|
||||
?.get(activeCategory.spec.names.kind)
|
||||
?? []
|
||||
);
|
||||
const fromCategory = activeCategory.spec.displayColumns?.map(({ priority = 50, ...column }) => ({
|
||||
priority,
|
||||
...column,
|
||||
})) ?? defaultCategoryColumns;
|
||||
|
||||
return [
|
||||
nameCategoryColumn,
|
||||
...fromExtensions,
|
||||
...fromCategory,
|
||||
];
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default getColumnsForCategoryInjectable;
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import { computedInjectManyInjectable } from "@ogre-tools/injectable-extension-for-mobx";
|
||||
import { computed } from "mobx";
|
||||
import { getOrInsert, getOrInsertMap } from "../../utils";
|
||||
import { customCatalogCategoryColumnInjectionToken } from "./columns/custom-token";
|
||||
import type { RegisteredAdditionalCategoryColumn } from "./custom-category-columns";
|
||||
|
||||
const categoryColumnsInjectable = getInjectable({
|
||||
id: "category-columns",
|
||||
instantiate: (di) => {
|
||||
const computedInjectMany = di.inject(computedInjectManyInjectable);
|
||||
const columnRegistrations = computedInjectMany(customCatalogCategoryColumnInjectionToken);
|
||||
|
||||
return computed(() => {
|
||||
const res = new Map<string, Map<string, RegisteredAdditionalCategoryColumn[]>>();
|
||||
|
||||
for (const { group, kind, registration } of columnRegistrations.get()) {
|
||||
const byGroup = getOrInsertMap(res, group);
|
||||
const byKind = getOrInsert(byGroup, kind, []);
|
||||
|
||||
byKind.push(registration);
|
||||
}
|
||||
|
||||
return res;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default categoryColumnsInjectable;
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import type React from "react";
|
||||
import type { CatalogEntity } from "../../../common/catalog";
|
||||
import type { TableCellProps } from "../table";
|
||||
|
||||
/**
|
||||
* These are the supported props for the title cell
|
||||
*/
|
||||
export interface TitleCellProps {
|
||||
className?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface CategoryColumnRegistration {
|
||||
/**
|
||||
* The sorting order value.
|
||||
*
|
||||
* @default 50
|
||||
*/
|
||||
priority?: number;
|
||||
|
||||
/**
|
||||
* This value MUST to be unique to your extension
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* This function will be called to generate the cells (on demand) for the column
|
||||
*/
|
||||
renderCell: (entity: CatalogEntity) => React.ReactNode;
|
||||
|
||||
/**
|
||||
* This function will be used to generate the columns title cell.
|
||||
*/
|
||||
titleProps: TitleCellProps;
|
||||
|
||||
/**
|
||||
* If provided then the column will support sorting and this function will be called to
|
||||
* determine a row's ordering.
|
||||
*
|
||||
* strings are sorted ahead of numbers, and arrays determine ordering between equal
|
||||
* elements of the previous index.
|
||||
*/
|
||||
sortCallback?: (entity: CatalogEntity) => string | number | (string | number)[];
|
||||
|
||||
/**
|
||||
* If provided then searching is supported on this column and this function will be called
|
||||
* to determine if the current search string matches for this row.
|
||||
*/
|
||||
searchFilter?: (entity: CatalogEntity) => string | string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the type used to declare new catalog category columns
|
||||
*/
|
||||
export interface AdditionalCategoryColumnRegistration extends CategoryColumnRegistration {
|
||||
/**
|
||||
* The catalog entity kind that is declared by the category for this registration
|
||||
*
|
||||
* e.g.
|
||||
* - `"KubernetesCluster"`
|
||||
*/
|
||||
kind: string;
|
||||
|
||||
/**
|
||||
* The catalog entity group that is declared by the category for this registration
|
||||
*
|
||||
* e.g.
|
||||
* - `"entity.k8slens.dev"`
|
||||
*/
|
||||
group: string;
|
||||
}
|
||||
|
||||
export interface RegisteredAdditionalCategoryColumn {
|
||||
id: string;
|
||||
priority: number;
|
||||
renderCell: (entity: CatalogEntity) => React.ReactNode;
|
||||
titleProps: TableCellProps;
|
||||
sortCallback?: (entity: CatalogEntity) => string | number | (string | number)[];
|
||||
searchFilter?: (entity: CatalogEntity) => string | string[];
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import { orderBy } from "lodash";
|
||||
import type { IComputedValue } from "mobx";
|
||||
import { computed } from "mobx";
|
||||
import type { LensRendererExtension } from "../../../extensions/lens-renderer-extension";
|
||||
import rendererExtensionsInjectable from "../../../extensions/renderer-extensions.injectable";
|
||||
import { getOrInsert, getOrInsertMap } from "../../utils";
|
||||
import type { CustomCategoryViewComponents } from "./custom-views";
|
||||
|
||||
interface Dependencies {
|
||||
extensions: IComputedValue<LensRendererExtension[]>;
|
||||
}
|
||||
|
||||
export interface RegisteredCustomCategoryViewDecl {
|
||||
/**
|
||||
* The asc sorted list of items with priority set to < 50
|
||||
*/
|
||||
before: CustomCategoryViewComponents[];
|
||||
/**
|
||||
* The asc sorted list of items with priority not set or set to >= 50
|
||||
*/
|
||||
after: CustomCategoryViewComponents[];
|
||||
}
|
||||
|
||||
function getCustomCategoryViews({ extensions }: Dependencies): IComputedValue<Map<string, Map<string, RegisteredCustomCategoryViewDecl>>> {
|
||||
return computed(() => {
|
||||
const res = new Map<string, Map<string, RegisteredCustomCategoryViewDecl>>();
|
||||
const registrations = extensions.get()
|
||||
.flatMap(ext => ext.customCategoryViews)
|
||||
.map(({ priority = 50, ...rest }) => ({ priority, ...rest }));
|
||||
const sortedRegistrations = orderBy(registrations, "priority", "asc");
|
||||
|
||||
for (const { priority, group, kind, components } of sortedRegistrations) {
|
||||
const byGroup = getOrInsertMap(res, group);
|
||||
const { before, after } = getOrInsert(byGroup, kind, { before: [], after: [] });
|
||||
|
||||
if (priority < 50) {
|
||||
before.push(components);
|
||||
} else {
|
||||
after.push(components);
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
const customCategoryViewsInjectable = getInjectable({
|
||||
id: "custom-category-views",
|
||||
|
||||
instantiate: (di) => getCustomCategoryViews({
|
||||
extensions: di.inject(rendererExtensionsInjectable),
|
||||
}),
|
||||
});
|
||||
|
||||
export default customCategoryViewsInjectable;
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import type React from "react";
|
||||
import type { CatalogCategory } from "../../api/catalog-entity";
|
||||
|
||||
/**
|
||||
* The props for CustomCategoryViewComponents.View
|
||||
*/
|
||||
export interface CustomCategoryViewProps {
|
||||
/**
|
||||
* The category instance itself
|
||||
*/
|
||||
category: CatalogCategory;
|
||||
}
|
||||
|
||||
/**
|
||||
* The components for the category view.
|
||||
*/
|
||||
export interface CustomCategoryViewComponents {
|
||||
View: React.ComponentType<CustomCategoryViewProps>;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the type used to declare additional views for a specific category
|
||||
*/
|
||||
export interface CustomCategoryViewRegistration {
|
||||
/**
|
||||
* The catalog entity kind that is declared by the category for this registration
|
||||
*
|
||||
* e.g.
|
||||
* - `"KubernetesCluster"`
|
||||
*/
|
||||
kind: string;
|
||||
|
||||
/**
|
||||
* The catalog entity group that is declared by the category for this registration
|
||||
*
|
||||
* e.g.
|
||||
* - `"entity.k8slens.dev"`
|
||||
*/
|
||||
group: string;
|
||||
|
||||
/**
|
||||
* The sorting order value. Used to determine the total order of the views.
|
||||
*
|
||||
* @default 50
|
||||
*/
|
||||
priority?: number;
|
||||
|
||||
/**
|
||||
* The components for this registration
|
||||
*/
|
||||
components: CustomCategoryViewComponents;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
|
||||
import { computedInjectManyInjectable } from "@ogre-tools/injectable-extension-for-mobx";
|
||||
import { computed } from "mobx";
|
||||
import { byOrderNumber } from "../../../../common/utils/composable-responsibilities/orderable/orderable";
|
||||
import type { CatalogEntity } from "../../../api/catalog-entity";
|
||||
import { catalogEntityDetailItemInjectionToken } from "./token";
|
||||
|
||||
const catalogEntityDetailItemsInjectable = getInjectable({
|
||||
id: "catalog-entity-detail-items",
|
||||
instantiate: (di, entity) => {
|
||||
const computedInjectMany = di.inject(computedInjectManyInjectable);
|
||||
const detailItems = computedInjectMany(catalogEntityDetailItemInjectionToken);
|
||||
|
||||
return computed(() => (
|
||||
detailItems.get()
|
||||
.filter(item => (
|
||||
item.apiVersions.has(entity.apiVersion)
|
||||
&& item.kind === entity.kind
|
||||
))
|
||||
.sort(byOrderNumber)
|
||||
.map(item => item.components.Details)
|
||||
));
|
||||
},
|
||||
lifecycle: lifecycleEnum.keyedSingleton({
|
||||
getInstanceKey: (di, entity: CatalogEntity) => `${entity.apiVersion}/${entity.kind}`,
|
||||
}),
|
||||
});
|
||||
|
||||
export default catalogEntityDetailItemsInjectable;
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import React from "react";
|
||||
import { KubernetesCluster } from "../../../../../common/catalog-entities";
|
||||
import { DrawerTitle, DrawerItem } from "../../../drawer";
|
||||
import { catalogEntityDetailItemInjectionToken } from "../token";
|
||||
|
||||
const kubernetesClusterDetailsItemInjectable = getInjectable({
|
||||
id: "kubernetes-cluster-details-item",
|
||||
instantiate: () => ({
|
||||
apiVersions: new Set([KubernetesCluster.apiVersion]),
|
||||
kind: KubernetesCluster.kind,
|
||||
orderNumber: 40,
|
||||
components: {
|
||||
Details: ({ entity }) => (
|
||||
<>
|
||||
<DrawerTitle>Kubernetes Information</DrawerTitle>
|
||||
<div className="box grow EntityMetadata">
|
||||
<DrawerItem
|
||||
name="Distribution"
|
||||
data-testid={`kubernetes-distro-for-${entity.getId()}`}
|
||||
>
|
||||
{entity.metadata.distro || "unknown"}
|
||||
</DrawerItem>
|
||||
<DrawerItem name="Kubelet Version">
|
||||
{entity.metadata.kubeVersion || "unknown"}
|
||||
</DrawerItem>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
}),
|
||||
injectionToken: catalogEntityDetailItemInjectionToken,
|
||||
});
|
||||
|
||||
export default kubernetesClusterDetailsItemInjectable;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import React from "react";
|
||||
import { WebLink } from "../../../../../common/catalog-entities";
|
||||
import { DrawerTitle, DrawerItem } from "../../../drawer";
|
||||
import { catalogEntityDetailItemInjectionToken } from "../token";
|
||||
|
||||
const weblinkDetailsItemInjectable = getInjectable({
|
||||
id: "weblink-details-item",
|
||||
instantiate: () => ({
|
||||
apiVersions: new Set([WebLink.apiVersion]),
|
||||
kind: WebLink.kind,
|
||||
components: {
|
||||
Details: ({ entity }) => (
|
||||
<>
|
||||
<DrawerTitle>More Information</DrawerTitle>
|
||||
<DrawerItem
|
||||
name="URL"
|
||||
data-testid={`weblink-url-for-${entity.getId()}`}
|
||||
>
|
||||
{entity.spec.url}
|
||||
</DrawerItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
orderNumber: 40,
|
||||
}),
|
||||
injectionToken: catalogEntityDetailItemInjectionToken,
|
||||
});
|
||||
|
||||
export default weblinkDetailsItemInjectable;
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import { extensionRegistratorInjectionToken } from "../../../../extensions/extension-loader/extension-registrator-injection-token";
|
||||
import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
|
||||
import type { CatalogEntity } from "../../../api/catalog-entity";
|
||||
import { getRandId } from "../../../utils";
|
||||
import type { CatalogEntityDetailRegistration } from "./token";
|
||||
import { catalogEntityDetailItemInjectionToken } from "./token";
|
||||
|
||||
const catalogEntityDetailItemsRegistratorInjectable = getInjectable({
|
||||
id: "catalog-entity-detail-items-registrator",
|
||||
instantiate: () => (ext) => {
|
||||
const extension = ext as LensRendererExtension;
|
||||
|
||||
return extension.catalogEntityDetailItems.map(getRegistratorFor(extension));
|
||||
},
|
||||
injectionToken: extensionRegistratorInjectionToken,
|
||||
});
|
||||
|
||||
export default catalogEntityDetailItemsRegistratorInjectable;
|
||||
|
||||
const getRegistratorFor = (extension: LensRendererExtension) => ({
|
||||
apiVersions,
|
||||
components,
|
||||
kind,
|
||||
priority,
|
||||
}: CatalogEntityDetailRegistration<CatalogEntity>) => getInjectable({
|
||||
id: `catalog-entity-detail-item-for-${extension.sanitizedExtensionId}-${getRandId({ sep: "-" })}`,
|
||||
instantiate: () => ({
|
||||
apiVersions: new Set(apiVersions),
|
||||
components,
|
||||
kind,
|
||||
orderNumber: priority ?? 50,
|
||||
}),
|
||||
injectionToken: catalogEntityDetailItemInjectionToken,
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import { getInjectionToken } from "@ogre-tools/injectable";
|
||||
import type { CatalogEntity } from "../../../api/catalog-entity";
|
||||
|
||||
export interface CatalogEntityDetailsProps<T extends CatalogEntity> {
|
||||
entity: T;
|
||||
}
|
||||
|
||||
export type CatalogEntityDetailsComponent<T extends CatalogEntity> = React.ComponentType<CatalogEntityDetailsProps<T>>;
|
||||
|
||||
export interface CatalogEntityDetailComponents<T extends CatalogEntity> {
|
||||
Details: CatalogEntityDetailsComponent<T>;
|
||||
}
|
||||
|
||||
export interface CatalogEntityDetailRegistration<T extends CatalogEntity> {
|
||||
kind: string;
|
||||
apiVersions: string[];
|
||||
components: CatalogEntityDetailComponents<T>;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface CatalogEntityDetailItem {
|
||||
kind: string;
|
||||
apiVersions: Set<string>;
|
||||
components: CatalogEntityDetailComponents<CatalogEntity>;
|
||||
orderNumber: number;
|
||||
}
|
||||
|
||||
export const catalogEntityDetailItemInjectionToken = getInjectionToken<CatalogEntityDetailItem>({
|
||||
id: "catalog-entity-detail-item-token",
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
.entityDetails {
|
||||
/* Move Details panel under TopBar line */
|
||||
height: calc(100% - var(--main-layout-header));
|
||||
margin-top: var(--main-layout-header);
|
||||
}
|
||||
|
||||
.metadata {
|
||||
margin-right: var(--margin);
|
||||
}
|
||||
|
||||
.entityIcon {
|
||||
margin-right: calc(var(--margin) * 3);
|
||||
|
||||
.avatar {
|
||||
font-size: 3ch;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hint {
|
||||
text-align: center;
|
||||
font-size: var(--font-size-small);
|
||||
text-transform: uppercase;
|
||||
margin-top: var(--margin);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import styles from "./view.module.scss";
|
||||
import React, { Component } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Drawer, DrawerItem } from "../../drawer";
|
||||
import type { CatalogCategory, CatalogEntity } from "../../../../common/catalog";
|
||||
import { Icon } from "../../icon";
|
||||
import { CatalogEntityDrawerMenu } from "../catalog-entity-drawer-menu";
|
||||
import { cssNames } from "../../../utils";
|
||||
import { Avatar } from "../../avatar";
|
||||
import type { GetLabelBadges } from "../get-label-badges.injectable";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import getLabelBadgesInjectable from "../get-label-badges.injectable";
|
||||
import isDevelopmentInjectable from "../../../../common/vars/is-development.injectable";
|
||||
import type { IComputedValue } from "mobx";
|
||||
import type { CatalogEntityDetailsComponent } from "./token";
|
||||
import catalogEntityDetailItemsInjectable from "./detail-items.injectable";
|
||||
|
||||
export interface CatalogEntityDetailsProps<Entity extends CatalogEntity> {
|
||||
entity: Entity;
|
||||
hideDetails(): void;
|
||||
onRun: () => void;
|
||||
}
|
||||
|
||||
interface Dependencies {
|
||||
getLabelBadges: GetLabelBadges;
|
||||
isDevelopment: boolean;
|
||||
detailItems: IComputedValue<CatalogEntityDetailsComponent<CatalogEntity>[]>;
|
||||
}
|
||||
|
||||
@observer
|
||||
class NonInjectedCatalogEntityDetails<Entity extends CatalogEntity> extends Component<CatalogEntityDetailsProps<Entity> & Dependencies> {
|
||||
categoryIcon(category: CatalogCategory) {
|
||||
if (Icon.isSvg(category.metadata.icon)) {
|
||||
return <Icon svg={category.metadata.icon} smallest />;
|
||||
} else {
|
||||
return <Icon material={category.metadata.icon} smallest />;
|
||||
}
|
||||
}
|
||||
|
||||
renderContent(entity: Entity) {
|
||||
const { onRun, hideDetails, getLabelBadges, isDevelopment, detailItems } = this.props;
|
||||
const details = detailItems.get().map((Details, index) => <Details entity={entity} key={index} />);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex" data-testid={`catalog-entity-details-content-for-${entity.getId()}`}>
|
||||
<div className={styles.entityIcon}>
|
||||
<Avatar
|
||||
title={entity.getName()}
|
||||
colorHash={`${entity.getName()}-${entity.getSource()}`}
|
||||
size={128}
|
||||
src={entity.spec.icon?.src}
|
||||
data-testid="detail-panel-hot-bar-icon"
|
||||
background={entity.spec.icon?.background}
|
||||
onClick={onRun}
|
||||
className={styles.avatar}
|
||||
>
|
||||
{entity.spec.icon?.material && <Icon material={entity.spec.icon?.material}/>}
|
||||
</Avatar>
|
||||
{entity.isEnabled() && (
|
||||
<div className={styles.hint}>
|
||||
Click to open
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={cssNames("box grow", styles.metadata)}>
|
||||
<DrawerItem name="Name">
|
||||
{entity.getName()}
|
||||
</DrawerItem>
|
||||
<DrawerItem name="Kind">
|
||||
{entity.kind}
|
||||
</DrawerItem>
|
||||
<DrawerItem name="Source">
|
||||
{entity.getSource()}
|
||||
</DrawerItem>
|
||||
<DrawerItem name="Status">
|
||||
{entity.status.phase}
|
||||
</DrawerItem>
|
||||
<DrawerItem name="Labels">
|
||||
{getLabelBadges(entity, hideDetails)}
|
||||
</DrawerItem>
|
||||
{isDevelopment && (
|
||||
<DrawerItem name="Id">
|
||||
{entity.getId()}
|
||||
</DrawerItem>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="box grow">
|
||||
{details}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { entity, hideDetails } = this.props;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
className={styles.entityDetails}
|
||||
usePortal={true}
|
||||
open={true}
|
||||
title={`${entity.kind}: ${entity.getName()}`}
|
||||
toolbar={<CatalogEntityDrawerMenu entity={entity} key={entity.getId()} />}
|
||||
onClose={hideDetails}
|
||||
data-testid="catalog-entity-details-drawer"
|
||||
>
|
||||
{this.renderContent(entity)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const CatalogEntityDetails = withInjectables<Dependencies, CatalogEntityDetailsProps<CatalogEntity>>(NonInjectedCatalogEntityDetails, {
|
||||
getProps: (di, props) => ({
|
||||
...props,
|
||||
getLabelBadges: di.inject(getLabelBadgesInjectable),
|
||||
isDevelopment: di.inject(isDevelopmentInjectable),
|
||||
detailItems: di.inject(catalogEntityDetailItemsInjectable, props.entity),
|
||||
}),
|
||||
}) as <Entity extends CatalogEntity>(props: CatalogEntityDetailsProps<Entity>) => React.ReactElement;
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import styles from "./catalog.module.scss";
|
||||
import React from "react";
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import type { CatalogEntity } from "../../api/catalog-entity";
|
||||
import searchUrlPageParamInjectable from "../input/search-url-page-param.injectable";
|
||||
import { KubeObject } from "../../../common/k8s-api/kube-object";
|
||||
import { Badge } from "../badge";
|
||||
|
||||
export type GetLabelBadges = (entity: CatalogEntity, onClick?: (evt: React.MouseEvent<any, MouseEvent>) => void) => JSX.Element[];
|
||||
|
||||
const getLabelBadgesInjectable = getInjectable({
|
||||
id: "get-label-badges",
|
||||
instantiate: (di): GetLabelBadges => {
|
||||
const searchUrlParam = di.inject(searchUrlPageParamInjectable);
|
||||
|
||||
return (entity, onClick) => (
|
||||
KubeObject.stringifyLabels(entity.metadata.labels)
|
||||
.map(label => (
|
||||
<Badge
|
||||
scrollable
|
||||
className={styles.badge}
|
||||
key={label}
|
||||
label={label}
|
||||
title={label}
|
||||
onClick={(event) => {
|
||||
searchUrlParam.set(label);
|
||||
onClick?.(event);
|
||||
event.stopPropagation();
|
||||
}}
|
||||
expandable={false}
|
||||
/>
|
||||
))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default getLabelBadgesInjectable;
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { MenuItem } from "../menu";
|
||||
|
||||
import type { CatalogEntity } from "../../api/catalog-entity";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import hotbarStoreInjectable from "../../../common/hotbars/store.injectable";
|
||||
import type { HotbarStore } from "../../../common/hotbars/store";
|
||||
|
||||
interface Dependencies {
|
||||
hotbarStore: HotbarStore;
|
||||
}
|
||||
|
||||
interface HotbarToggleMenuItemProps {
|
||||
entity: CatalogEntity;
|
||||
addContent: ReactNode;
|
||||
removeContent: ReactNode;
|
||||
}
|
||||
|
||||
function NonInjectedHotbarToggleMenuItem({
|
||||
addContent,
|
||||
entity,
|
||||
hotbarStore,
|
||||
removeContent,
|
||||
}: Dependencies & HotbarToggleMenuItemProps) {
|
||||
const [itemInHotbar, setItemInHotbar] = useState(hotbarStore.isAddedToActive(entity));
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
if (itemInHotbar) {
|
||||
hotbarStore.removeFromHotbar(entity.getId());
|
||||
setItemInHotbar(false);
|
||||
} else {
|
||||
hotbarStore.addToHotbar(entity);
|
||||
setItemInHotbar(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{itemInHotbar ? removeContent : addContent }
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export const HotbarToggleMenuItem = withInjectables<Dependencies, HotbarToggleMenuItemProps>(
|
||||
NonInjectedHotbarToggleMenuItem,
|
||||
|
||||
{
|
||||
getProps: (di, props) => ({
|
||||
hotbarStore: di.inject(hotbarStoreInjectable),
|
||||
...props,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
export * from "./catalog";
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
.ClusterIssues {
|
||||
min-height: 350px;
|
||||
position: relative;
|
||||
grid-area: issues;
|
||||
padding: calc(var(--margin) * 2) 0;
|
||||
background: var(--contentColor);
|
||||
|
||||
&:global(.OnlyClusterIssues) {
|
||||
grid-row: row1-start / row2-end;
|
||||
}
|
||||
|
||||
@media (max-width: 1150px) {
|
||||
grid-area: unset;
|
||||
|
||||
&:global(.OnlyClusterIssues) {
|
||||
grid-row: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.SubHeader {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
:global(.Icon) {
|
||||
font-size: 130%;
|
||||
color: var(--colorError);
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
flex-grow: 3;
|
||||
}
|
||||
|
||||
.object {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
flex-grow: 2;
|
||||
}
|
||||
|
||||
.noIssues {
|
||||
.Icon {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: large;
|
||||
color: var(--textColorAccent);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import styles from "./cluster-issues.module.scss";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { IComputedValue } from "mobx";
|
||||
import { computed, makeObservable } from "mobx";
|
||||
import { Icon } from "../icon";
|
||||
import { SubHeader } from "../layout/sub-header";
|
||||
import { Table, TableCell, TableHead, TableRow } from "../table";
|
||||
import { cssNames, prevDefault } from "../../utils";
|
||||
import type { ItemObject } from "../../../common/item.store";
|
||||
import { Spinner } from "../spinner";
|
||||
import type { ApiManager } from "../../../common/k8s-api/api-manager";
|
||||
import { KubeObjectAge } from "../kube-object/age";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import type { NodeStore } from "../+nodes/store";
|
||||
import type { EventStore } from "../+events/store";
|
||||
import apiManagerInjectable from "../../../common/k8s-api/api-manager/manager.injectable";
|
||||
import eventStoreInjectable from "../+events/store.injectable";
|
||||
import nodeStoreInjectable from "../+nodes/store.injectable";
|
||||
import type { PageParam } from "../../navigation/page-param";
|
||||
import type { ToggleKubeDetailsPane } from "../kube-detail-params/toggle-details.injectable";
|
||||
import kubeSelectedUrlParamInjectable from "../kube-detail-params/kube-selected-url.injectable";
|
||||
import toggleKubeDetailsPaneInjectable from "../kube-detail-params/toggle-details.injectable";
|
||||
import type { LensTheme } from "../../themes/lens-theme";
|
||||
import activeThemeInjectable from "../../themes/active.injectable";
|
||||
|
||||
export interface ClusterIssuesProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface Warning extends ItemObject {
|
||||
kind: string;
|
||||
message: string | undefined;
|
||||
selfLink: string;
|
||||
renderAge: () => React.ReactElement;
|
||||
ageMs: number;
|
||||
}
|
||||
|
||||
enum sortBy {
|
||||
type = "type",
|
||||
object = "object",
|
||||
age = "age",
|
||||
}
|
||||
|
||||
interface Dependencies {
|
||||
activeTheme: IComputedValue<LensTheme>;
|
||||
nodeStore: NodeStore;
|
||||
eventStore: EventStore;
|
||||
apiManager: ApiManager;
|
||||
kubeSelectedUrlParam: PageParam<string>;
|
||||
toggleKubeDetailsPane: ToggleKubeDetailsPane;
|
||||
}
|
||||
|
||||
@observer
|
||||
class NonInjectedClusterIssues extends React.Component<ClusterIssuesProps & Dependencies> {
|
||||
constructor(props: ClusterIssuesProps & Dependencies) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
@computed get warnings(): Warning[] {
|
||||
return [
|
||||
...this.props.nodeStore.items.flatMap(node => (
|
||||
node.getWarningConditions()
|
||||
.map(({ message }) => ({
|
||||
selfLink: node.selfLink,
|
||||
getId: node.getId,
|
||||
getName: node.getName,
|
||||
kind: node.kind,
|
||||
message,
|
||||
renderAge: () => <KubeObjectAge key="age" object={node} />,
|
||||
ageMs: -node.getCreationTimestamp(),
|
||||
}))
|
||||
)),
|
||||
...this.props.eventStore.getWarnings().map(warning => ({
|
||||
getId: () => warning.involvedObject.uid,
|
||||
getName: () => warning.involvedObject.name,
|
||||
renderAge: () => <KubeObjectAge key="age" object={warning} />,
|
||||
ageMs: -warning.getCreationTimestamp(),
|
||||
message: warning.message,
|
||||
kind: warning.kind,
|
||||
selfLink: this.props.apiManager.lookupApiLink(warning.involvedObject, warning),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
getTableRow = (uid: string) => {
|
||||
const { warnings } = this;
|
||||
const { kubeSelectedUrlParam, toggleKubeDetailsPane: toggleDetails } = this.props;
|
||||
const warning = warnings.find(warn => warn.getId() == uid);
|
||||
|
||||
if (!warning) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { getId, getName, message, kind, selfLink, renderAge } = warning;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={getId()}
|
||||
sortItem={warning}
|
||||
selected={selfLink === kubeSelectedUrlParam.get()}
|
||||
onClick={prevDefault(() => toggleDetails(selfLink))}
|
||||
>
|
||||
<TableCell className={styles.message}>
|
||||
{message ?? "<unknown>"}
|
||||
</TableCell>
|
||||
<TableCell className={styles.object}>
|
||||
{getName()}
|
||||
</TableCell>
|
||||
<TableCell className="kind">
|
||||
{kind}
|
||||
</TableCell>
|
||||
<TableCell className="age">
|
||||
{renderAge()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
renderContent() {
|
||||
const { warnings } = this;
|
||||
|
||||
if (!this.props.eventStore.isLoaded) {
|
||||
return (
|
||||
<Spinner center/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!warnings.length) {
|
||||
return (
|
||||
<div className={cssNames(styles.noIssues, "flex column box grow gaps align-center justify-center")}>
|
||||
<Icon
|
||||
className={styles.Icon}
|
||||
material="check"
|
||||
big
|
||||
sticker
|
||||
/>
|
||||
<p className={styles.title}>No issues found</p>
|
||||
<p>Everything is fine in the Cluster</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubHeader className={styles.SubHeader}>
|
||||
<Icon material="error_outline"/>
|
||||
{` Warnings: ${warnings.length}`}
|
||||
</SubHeader>
|
||||
<Table
|
||||
tableId="cluster_issues"
|
||||
items={warnings}
|
||||
virtual
|
||||
selectable
|
||||
sortable={{
|
||||
[sortBy.type]: warning => warning.kind,
|
||||
[sortBy.object]: warning => warning.getName(),
|
||||
[sortBy.age]: warning => warning.ageMs,
|
||||
}}
|
||||
sortByDefault={{ sortBy: sortBy.object, orderBy: "asc" }}
|
||||
sortSyncWithUrl={false}
|
||||
getTableRow={this.getTableRow}
|
||||
className={cssNames("box grow", this.props.activeTheme.get().type)}
|
||||
>
|
||||
<TableHead nowrap>
|
||||
<TableCell className="message">Message</TableCell>
|
||||
<TableCell className="object" sortBy={sortBy.object}>Object</TableCell>
|
||||
<TableCell className="kind" sortBy={sortBy.type}>Type</TableCell>
|
||||
<TableCell className="timestamp" sortBy={sortBy.age}>Age</TableCell>
|
||||
</TableHead>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={cssNames(styles.ClusterIssues, "flex column", this.props.className)}>
|
||||
{this.renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const ClusterIssues = withInjectables<Dependencies, ClusterIssuesProps>(NonInjectedClusterIssues, {
|
||||
getProps: (di, props) => ({
|
||||
...props,
|
||||
activeTheme: di.inject(activeThemeInjectable),
|
||||
apiManager: di.inject(apiManagerInjectable),
|
||||
eventStore: di.inject(eventStoreInjectable),
|
||||
nodeStore: di.inject(nodeStoreInjectable),
|
||||
kubeSelectedUrlParam: di.inject(kubeSelectedUrlParamInjectable),
|
||||
toggleKubeDetailsPane: di.inject(toggleKubeDetailsPaneInjectable),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { NodeStore } from "../+nodes/store";
|
||||
import { Radio, RadioGroup } from "../radio";
|
||||
import type { ClusterOverviewStore } from "./cluster-overview-store/cluster-overview-store";
|
||||
import { MetricNodeRole, MetricType } from "./cluster-overview-store/cluster-overview-store";
|
||||
import clusterOverviewStoreInjectable from "./cluster-overview-store/cluster-overview-store.injectable";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import nodeStoreInjectable from "../+nodes/store.injectable";
|
||||
import { normalizeMetrics } from "../../../common/k8s-api/endpoints/metrics.api";
|
||||
|
||||
interface Dependencies {
|
||||
clusterOverviewStore: ClusterOverviewStore;
|
||||
nodeStore: NodeStore;
|
||||
}
|
||||
|
||||
const NonInjectedClusterMetricSwitchers = observer(({
|
||||
clusterOverviewStore,
|
||||
nodeStore,
|
||||
}: Dependencies) => {
|
||||
const { masterNodes, workerNodes } = nodeStore;
|
||||
const { cpuUsage, memoryUsage } = clusterOverviewStore.metrics ?? {};
|
||||
const hasMasterNodes = masterNodes.length > 0;
|
||||
const hasWorkerNodes = workerNodes.length > 0;
|
||||
const hasCpuMetrics = normalizeMetrics(cpuUsage).data.result[0].values.length > 0;
|
||||
const hasMemoryMetrics = normalizeMetrics(memoryUsage).data.result[0].values.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex gaps" style={{ marginBottom: "calc(var(--margin) * 2)" }}>
|
||||
<div className="box grow">
|
||||
<RadioGroup
|
||||
asButtons
|
||||
className="RadioGroup flex gaps"
|
||||
value={clusterOverviewStore.metricNodeRole}
|
||||
onChange={metric => clusterOverviewStore.metricNodeRole = metric}
|
||||
>
|
||||
<Radio
|
||||
label="Master"
|
||||
value={MetricNodeRole.MASTER}
|
||||
disabled={!hasMasterNodes}
|
||||
/>
|
||||
<Radio
|
||||
label="Worker"
|
||||
value={MetricNodeRole.WORKER}
|
||||
disabled={!hasWorkerNodes}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className="box grow" style={{ textAlign: "right" }}>
|
||||
<RadioGroup
|
||||
asButtons
|
||||
className="RadioGroup flex gaps"
|
||||
value={clusterOverviewStore.metricType}
|
||||
onChange={value => clusterOverviewStore.metricType = value}
|
||||
>
|
||||
<Radio
|
||||
label="CPU"
|
||||
value={MetricType.CPU}
|
||||
disabled={!hasCpuMetrics}
|
||||
/>
|
||||
<Radio
|
||||
label="Memory"
|
||||
value={MetricType.MEMORY}
|
||||
disabled={!hasMemoryMetrics}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const ClusterMetricSwitchers = withInjectables<Dependencies>(NonInjectedClusterMetricSwitchers, {
|
||||
getProps: (di) => ({
|
||||
clusterOverviewStore: di.inject(clusterOverviewStoreInjectable),
|
||||
nodeStore: di.inject(nodeStoreInjectable),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
.ClusterMetrics {
|
||||
position: relative;
|
||||
min-height: 280px;
|
||||
padding: calc(var(--margin) * 2);
|
||||
background: var(--contentColor);
|
||||
|
||||
.chart {
|
||||
:global(.chart-container) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding-bottom: 45px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import styles from "./cluster-metrics.module.scss";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { ChartOptions, ChartPoint } from "chart.js";
|
||||
import type { ClusterOverviewStore } from "./cluster-overview-store/cluster-overview-store";
|
||||
import { MetricType } from "./cluster-overview-store/cluster-overview-store";
|
||||
import { BarChart } from "../chart";
|
||||
import { bytesToUnits, cssNames } from "../../utils";
|
||||
import { Spinner } from "../spinner";
|
||||
import { ZebraStripesPlugin } from "../chart/zebra-stripes.plugin";
|
||||
import { ClusterNoMetrics } from "./cluster-no-metrics";
|
||||
import { ClusterMetricSwitchers } from "./cluster-metric-switchers";
|
||||
import { getMetricLastPoints } from "../../../common/k8s-api/endpoints/metrics.api";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import clusterOverviewStoreInjectable from "./cluster-overview-store/cluster-overview-store.injectable";
|
||||
|
||||
interface Dependencies {
|
||||
clusterOverviewStore: ClusterOverviewStore;
|
||||
}
|
||||
|
||||
const NonInjectedClusterMetrics = observer(({ clusterOverviewStore: { metricType, metricNodeRole, getMetricsValues, metricsLoaded, metrics }}: Dependencies) => {
|
||||
const [plugins] = useState([new ZebraStripesPlugin()]);
|
||||
const { memoryCapacity, cpuCapacity } = getMetricLastPoints(metrics ?? {});
|
||||
const metricValues = getMetricsValues(metrics ?? {});
|
||||
const colors = { cpu: "#3D90CE", memory: "#C93DCE" };
|
||||
const data = metricValues.map(value => ({
|
||||
x: value[0],
|
||||
y: parseFloat(value[1]).toFixed(3),
|
||||
}));
|
||||
|
||||
const datasets = [{
|
||||
id: metricType + metricNodeRole,
|
||||
label: `${metricType.toUpperCase()} usage`,
|
||||
borderColor: colors[metricType],
|
||||
data,
|
||||
}];
|
||||
const cpuOptions: ChartOptions = {
|
||||
scales: {
|
||||
yAxes: [{
|
||||
ticks: {
|
||||
suggestedMax: cpuCapacity,
|
||||
callback: (value) => value,
|
||||
},
|
||||
}],
|
||||
},
|
||||
tooltips: {
|
||||
callbacks: {
|
||||
label: ({ index }, data) => {
|
||||
if (!index) {
|
||||
return "<unknown>";
|
||||
}
|
||||
|
||||
const value = data.datasets?.[0].data?.[index] as ChartPoint;
|
||||
|
||||
return value.y?.toString() ?? "<unknown>";
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const memoryOptions: ChartOptions = {
|
||||
scales: {
|
||||
yAxes: [{
|
||||
ticks: {
|
||||
suggestedMax: memoryCapacity,
|
||||
callback: (value: string) => !value ? 0 : bytesToUnits(parseInt(value)),
|
||||
},
|
||||
}],
|
||||
},
|
||||
tooltips: {
|
||||
callbacks: {
|
||||
label: ({ index }, data) => {
|
||||
if (!index) {
|
||||
return "<unknown>";
|
||||
}
|
||||
|
||||
const value = data.datasets?.[0].data?.[index] as ChartPoint;
|
||||
|
||||
return bytesToUnits(parseInt(value.y as string), { precision: 3 });
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const options = metricType === MetricType.CPU ? cpuOptions : memoryOptions;
|
||||
|
||||
const renderMetrics = () => {
|
||||
if (!metricValues.length && !metricsLoaded) {
|
||||
return <Spinner center/>;
|
||||
}
|
||||
|
||||
if (!memoryCapacity || !cpuCapacity) {
|
||||
return <ClusterNoMetrics className={styles.empty}/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<BarChart
|
||||
name={`${metricNodeRole}-${metricType}`}
|
||||
options={options}
|
||||
data={{ datasets }}
|
||||
timeLabelStep={5}
|
||||
showLegend={false}
|
||||
plugins={plugins}
|
||||
className={styles.chart}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cssNames(styles.ClusterMetrics, "flex column")}>
|
||||
<ClusterMetricSwitchers/>
|
||||
{renderMetrics()}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const ClusterMetrics = withInjectables<Dependencies>(
|
||||
NonInjectedClusterMetrics,
|
||||
|
||||
{
|
||||
getProps: (di) => ({
|
||||
clusterOverviewStore: di.inject(clusterOverviewStoreInjectable),
|
||||
}),
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
.ClusterNoMetrics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1.7;
|
||||
flex-grow: 1;
|
||||
|
||||
.link {
|
||||
color: var(--blue);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import styles from "./cluster-no-metrics.module.scss";
|
||||
|
||||
import React from "react";
|
||||
import { Icon } from "../icon";
|
||||
import { cssNames } from "../../utils";
|
||||
import type { NavigateToEntitySettings } from "../../../common/front-end-routing/routes/entity-settings/navigate-to-entity-settings.injectable";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import navigateToEntitySettingsInjectable from "../../../common/front-end-routing/routes/entity-settings/navigate-to-entity-settings.injectable";
|
||||
import hostedClusterInjectable from "../../cluster-frame-context/hosted-cluster.injectable";
|
||||
|
||||
export interface ClusterNoMetricsProps {
|
||||
className: string;
|
||||
}
|
||||
|
||||
interface Dependencies {
|
||||
navigateToEntitySettings: NavigateToEntitySettings;
|
||||
clusterId: string | undefined;
|
||||
}
|
||||
|
||||
export function NonInjectedClusterNoMetrics({ className, navigateToEntitySettings, clusterId }: Dependencies & ClusterNoMetricsProps) {
|
||||
function openMetricSettingsPage() {
|
||||
if (clusterId) {
|
||||
navigateToEntitySettings(clusterId, "metrics");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cssNames(styles.ClusterNoMetrics, className)} data-testid="no-metrics-message">
|
||||
<Icon material="info"/>
|
||||
<p>Metrics are not available due to missing or invalid Prometheus configuration.</p>
|
||||
<p><span className={styles.link} onClick={openMetricSettingsPage}>Open cluster settings</span></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ClusterNoMetrics = withInjectables<Dependencies, ClusterNoMetricsProps>(
|
||||
NonInjectedClusterNoMetrics,
|
||||
|
||||
{
|
||||
getProps: (di, props) => {
|
||||
const cluster = di.inject(hostedClusterInjectable);
|
||||
|
||||
return {
|
||||
navigateToEntitySettings: di.inject(navigateToEntitySettingsInjectable),
|
||||
clusterId: cluster?.id,
|
||||
...props,
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import { routeSpecificComponentInjectionToken } from "../../routes/route-specific-component-injection-token";
|
||||
import { ClusterOverview } from "./cluster-overview";
|
||||
import clusterOverviewRouteInjectable from "../../../common/front-end-routing/routes/cluster/overview/cluster-overview-route.injectable";
|
||||
|
||||
const clusterOverviewRouteComponentInjectable = getInjectable({
|
||||
id: "cluster-overview-route-component",
|
||||
|
||||
instantiate: (di) => ({
|
||||
route: di.inject(clusterOverviewRouteInjectable),
|
||||
Component: ClusterOverview,
|
||||
}),
|
||||
|
||||
injectionToken: routeSpecificComponentInjectionToken,
|
||||
});
|
||||
|
||||
export default clusterOverviewRouteComponentInjectable;
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import { Icon } from "../icon";
|
||||
import React from "react";
|
||||
import type {
|
||||
SidebarItemRegistration } from "../layout/sidebar-items.injectable";
|
||||
import {
|
||||
sidebarItemsInjectionToken,
|
||||
} from "../layout/sidebar-items.injectable";
|
||||
import { computed } from "mobx";
|
||||
import clusterOverviewRouteInjectable from "../../../common/front-end-routing/routes/cluster/overview/cluster-overview-route.injectable";
|
||||
import routeIsActiveInjectable from "../../routes/route-is-active.injectable";
|
||||
import navigateToClusterOverviewInjectable from "../../../common/front-end-routing/routes/cluster/overview/navigate-to-cluster-overview.injectable";
|
||||
|
||||
const clusterOverviewSidebarItemsInjectable = getInjectable({
|
||||
id: "cluster-overview-sidebar-items",
|
||||
|
||||
instantiate: (di) => {
|
||||
const route = di.inject(clusterOverviewRouteInjectable);
|
||||
const navigateToClusterOverview = di.inject(navigateToClusterOverviewInjectable);
|
||||
const routeIsActive = di.inject(routeIsActiveInjectable, route);
|
||||
|
||||
return computed((): SidebarItemRegistration[] => [
|
||||
{
|
||||
id: "cluster-overview",
|
||||
parentId: null,
|
||||
title: "Cluster",
|
||||
getIcon: () => <Icon svg="kube" />,
|
||||
onClick: navigateToClusterOverview,
|
||||
isActive: routeIsActive,
|
||||
isVisible: route.isEnabled,
|
||||
orderNumber: 10,
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
injectionToken: sidebarItemsInjectionToken,
|
||||
});
|
||||
|
||||
export default clusterOverviewSidebarItemsInjectable;
|
||||
+44
@@ -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;
|
||||
+118
@@ -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;
|
||||
}
|
||||
}
|
||||
+66
@@ -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;
|
||||
+38
@@ -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;
|
||||
+1102
File diff suppressed because it is too large.
Load diff
+21
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import { routeSpecificComponentInjectionToken } from "../../routes/route-specific-component-injection-token";
|
||||
import 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 >
|
||||
);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import { 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;
|
||||
+38
@@ -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),
|
||||
}),
|
||||
});
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import { 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;
|
||||
+38
@@ -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),
|
||||
}),
|
||||
});
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import { getInjectable } from "@ogre-tools/injectable";
|
||||
import { 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;
|
||||
+38
@@ -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
Reference in new issue
Block a user