Making apiBase injectable (#6022)

* Making apiBase injectable

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

* Convert all of Helm functions to be DI

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

* Make PortForward's use of apiBase fully injectable

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

* Convert all metric requests to be injectable

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

* Replace resource applier with injectables

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

* Switch KubeJsonApi.forCluster to be injectable but do not use

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

* Convert the rest of shell sessions to be DI-ed

- This is a prerequesit for using the new
  createKubeJsonApiForClusterInjectable

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

* Use new createKubeJsonApiForClusterInjectable for openNodeShellSession

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

* Make KubeconfigDialog injectable

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

* Remove jest-fetch-mock and make fetch injectable

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

* Fix tests with new global override

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

* Add new injectable for create KubeJsonApi and JsonApi instances

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

* Fix showing-details-for-helm-release behavioural tests

- Remove HelmChartStore in favour of all injectables

- Create a model for UpgradeChartDockTab

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

* Fix show details and updating helm releases tests

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

* Fix residual typing issues related to metrics

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

* Fix crash on load due to circular dependency

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

* Fix create resource tab not working

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

* Remove legacy apiBase global

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

* Introduce and use isDebuggingInjectable

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

* Introduce and use windowLocationInjectable

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

* Remove global legacy apiKube

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

* Improve injectable filenames compared to the injectables inside

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

* Remove modifying input in requestActivePortForwardInjectable

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

* Introduce and use get(Milli)SecondsFromUnixEpochInjectable

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

* Switch to non-reactive way of gettting possible helm release versions

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

* Fix typo

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

* Fix bug in KubeApi constructor

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

* Convert all KubeApi related tests to use asyncFn

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

* Fix unit tests after introducing new injectables that have side effects

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

* Fix bad rebase causing tests to fail

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

* Improve expects for multiple field values

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

* Fix crash will looking up api refs

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

* Fix breaking change on KubeApi.list

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

* Better fix for formatting urls

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

* Remove injectable for time since we should just use useMockTime

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

* Add happy path behavioural tests for upgrade chart tab

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

* Remove debug message

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

* Update snapshots

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

* fix showing-details-for-helm-release tests

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

* Fix installing-helm-chart-from-new-tab tests

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

* Fix tests relating to hosted cluster id

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

* Update snapshots to recent changes in master

Co-authored-by: Janne Savolainen <janne.savolainen@live.fi>

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Reupdated upgrade chart new tab test snapshots

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

* Fix flakiness in unit test when using <Animated>

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

* Fix flakiness and improve tests for DeleteClusterDialog

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

* Fix kubeconfig-sync tests

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

* Fix <Extensions> tests by removing mockFs and making everything injectable

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

* Fix build issues

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

* Fix getElectronAppPathInjectable override not returning absolute paths

- Also fixes the listing-active-helm-repos-in-prefs tests

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

* Replace all uses of getAbsolutePath with joinPaths as it is more correct and less confusing

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

* Fix opening application window tests by making override properly absolute

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

* Update snapshots relating no longer using getAbsolutePath

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

* Fix and add behavioural tests for RenderDelay

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

* Fix extension discovery tests

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

* Fix test flakiness because of path side effects, propagate uses to as many places

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

* Fix extension-discovery tests

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

* Add global override to fix some tests

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

* Rewrite and fix implementation of KubeconfigManager and its tests

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

* Fix tests by global override pathExists

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

* Fix unit tests failing on windows by using injectable verions of path functions

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

* Attempt to fix test timeout by using runInAction

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

* Update snapshots after rebase

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

* Update snapshots after rebase

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

* Fix tests after rebase

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

* Fix setupIpcMainHandlers usage

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

* Update snapshots

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

Signed-off-by: Sebastian Malton <sebastian@malton.name>
Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>
Co-authored-by: Iku-turso <mikko.aspiala@gmail.com>
This commit is contained in:
authored and GitHub committed 2022-10-05 08:10:36 -04:00
1 parent ed073d6562
commit 76066c5ebf
367 files changed
+16701 -5302

No files matched your search

-6
View File
@@ -1,6 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export { apiBase, apiKube } from "../../common/k8s-api";
-17
View File
@@ -1,17 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { Response } from "node-fetch";
import type { JsonApiErrorParsed } from "../../common/k8s-api/json-api";
import { Notifications } from "../components/notifications";
export function onApiError(error: JsonApiErrorParsed, res: Response) {
switch (res.status) {
case 403:
error.isUsedForNotification = true;
Notifications.error(error);
break;
}
}
@@ -1,24 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { apiBase } from "../../common/k8s-api";
import { onApiError } from "./on-api-error";
import { beforeFrameStartsInjectionToken } from "../before-frame-starts/before-frame-starts-injection-token";
const setupOnApiErrorListenersInjectable = getInjectable({
id: "setup-on-api-error-listeners",
instantiate: () => ({
id: "setup-on-api-error-listeners",
run: () => {
apiBase?.onError.addListener(onApiError);
},
}),
injectionToken: beforeFrameStartsInjectionToken,
causesSideEffects: true,
});
export default setupOnApiErrorListenersInjectable;
@@ -10,7 +10,6 @@ import fse from "fs-extra";
import { debounce } from "lodash";
import { action, computed, makeObservable, observable } from "mobx";
import { observer } from "mobx-react";
import path from "path";
import React from "react";
import * as uuid from "uuid";
import { appEventBus } from "../../../common/app-event-bus/event-bus";
@@ -25,6 +24,8 @@ import { withInjectables } from "@ogre-tools/injectable-react";
import getCustomKubeConfigDirectoryInjectable 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 { GetDirnameOfPath } from "../../../common/path/get-dirname.injectable";
import getDirnameOfPathInjectable from "../../../common/path/get-dirname.injectable";
interface Option {
config: KubeConfig;
@@ -34,6 +35,7 @@ interface Option {
interface Dependencies {
getCustomKubeConfigDirectory: (directoryName: string) => string;
navigateToCatalog: NavigateToCatalog;
getDirnameOfPath: GetDirnameOfPath;
}
function getContexts(config: KubeConfig): Map<string, Option> {
@@ -90,7 +92,7 @@ class NonInjectedAddCluster extends React.Component<Dependencies> {
try {
const absPath = this.props.getCustomKubeConfigDirectory(uuid.v4());
await fse.ensureDir(path.dirname(absPath));
await fse.ensureDir(this.props.getDirnameOfPath(absPath));
await fse.writeFile(absPath, this.customConfig.trim(), { encoding: "utf-8", mode: 0o600 });
Notifications.ok(`Successfully added ${this.kubeContexts.size} new cluster(s)`);
@@ -155,10 +157,8 @@ class NonInjectedAddCluster extends React.Component<Dependencies> {
export const AddCluster = withInjectables<Dependencies>(NonInjectedAddCluster, {
getProps: (di) => ({
getCustomKubeConfigDirectory: di.inject(
getCustomKubeConfigDirectoryInjectable,
),
getCustomKubeConfigDirectory: di.inject(getCustomKubeConfigDirectoryInjectable),
navigateToCatalog: di.inject(navigateToCatalogInjectable),
getDirnameOfPath: di.inject(getDirnameOfPathInjectable),
}),
});
@@ -9,23 +9,21 @@ 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 { catalogCategoryRegistry } from "../../api/catalog-category-registry";
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 getCategories() {
return catalogCategoryRegistry.filteredItems;
}
function getCategoryIcon(category: CatalogCategory) {
const { icon } = category.metadata ?? {};
@@ -44,44 +42,56 @@ function Item(props: TreeItemProps) {
);
}
export const CatalogMenu = observer((props: CatalogMenuProps) => {
return (
// 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={props.activeTab || "browse"}
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)}
>
<Item
nodeId="browse"
label="Browse"
data-testid="*-tab"
onClick={() => props.onItemClick("*")}
/>
<Item
nodeId="catalog"
label={<div className={styles.parent}>Categories</div>}
className={cssNames(styles.bordered)}
>
{
getCategories().map(category => (
{
filteredCategories.get()
.map(category => (
<Item
icon={getCategoryIcon(category)}
key={category.getId()}
nodeId={category.getId()}
label={<CatalogCategoryLabel category={category}/>}
label={<CatalogCategoryLabel category={category} />}
data-testid={`${category.getId()}-tab`}
onClick={() => props.onItemClick(category.getId())}
/>
onClick={() => onItemClick(category.getId())} />
))
}
</Item>
</TreeView>
</div>
</StylesProvider>
);
}
</Item>
</TreeView>
</div>
</StylesProvider>
));
export const CatalogMenu = withInjectables<Dependencies, CatalogMenuProps>(NonInjectedCatalogMenu, {
getProps: (di, props) => ({
...props,
filteredCategories: di.inject(filteredCategoriesInjectable),
}),
});
@@ -24,7 +24,7 @@ const NonInjectedClusterMetricSwitchers = observer(({
nodeStore,
}: Dependencies) => {
const { masterNodes, workerNodes } = nodeStore;
const { cpuUsage, memoryUsage } = clusterOverviewStore.metrics;
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;
@@ -26,8 +26,8 @@ interface Dependencies {
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 { memoryCapacity, cpuCapacity } = getMetricLastPoints(metrics ?? {});
const metricValues = getMetricsValues(metrics ?? {});
const colors = { cpu: "#3D90CE", memory: "#C93DCE" };
const data = metricValues.map(value => ({
x: value[0],
@@ -12,6 +12,7 @@ import clusterApiInjectable from "../../../../common/k8s-api/endpoints/cluster.a
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";
const clusterOverviewStoreInjectable = getInjectable({
id: "cluster-overview-store",
@@ -30,6 +31,7 @@ const clusterOverviewStoreInjectable = getInjectable({
},
),
nodeStore: di.inject(nodeStoreInjectable),
requestClusterMetricsByNodeNames: di.inject(requestClusterMetricsByNodeNamesInjectable),
}, clusterApi);
},
injectionToken: kubeObjectStoreInjectionToken,
@@ -6,11 +6,12 @@
import { action, observable, reaction, when, makeObservable } from "mobx";
import { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store";
import type { Cluster, ClusterApi } from "../../../../common/k8s-api/endpoints";
import { getMetricsByNodeNames, type ClusterMetricData } from "../../../../common/k8s-api/endpoints";
import type { StorageLayer } from "../../../utils";
import { autoBind } from "../../../utils";
import { type IMetricsReqParams, normalizeMetrics } from "../../../../common/k8s-api/endpoints/metrics.api";
import 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",
@@ -30,11 +31,15 @@ export interface ClusterOverviewStorageState {
interface ClusterOverviewStoreDependencies {
readonly storage: StorageLayer<ClusterOverviewStorageState>;
readonly nodeStore: NodeStore;
requestClusterMetricsByNodeNames: RequestClusterMetricsByNodeNames;
}
export class ClusterOverviewStore extends KubeObjectStore<Cluster, ClusterApi> implements ClusterOverviewStorageState {
@observable metrics: Partial<ClusterMetricData> = {};
@observable metricsLoaded = false;
@observable metrics: ClusterMetricData | undefined = undefined;
get metricsLoaded() {
return !!this.metrics;
}
get metricType(): MetricType {
return this.dependencies.storage.get().metricType;
@@ -64,9 +69,10 @@ export class ClusterOverviewStore extends KubeObjectStore<Cluster, ClusterApi> i
// TODO: refactor, seems not a correct place to be
// auto-refresh metrics on user-action
reaction(() => this.metricNodeRole, () => {
if (!this.metricsLoaded) return;
this.resetMetrics();
this.loadMetrics();
if (this.metrics) {
this.resetMetrics();
this.loadMetrics();
}
});
// check which node type to select
@@ -79,13 +85,12 @@ export class ClusterOverviewStore extends KubeObjectStore<Cluster, ClusterApi> i
}
@action
async loadMetrics(params?: IMetricsReqParams) {
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 getMetricsByNodeNames(nodes.map(node => node.getName()), params);
this.metricsLoaded = true;
this.metrics = await this.dependencies.requestClusterMetricsByNodeNames(nodes.map(node => node.getName()), params);
}
getMetricsValues(source: Partial<ClusterMetricData>): [number, string][] {
@@ -101,8 +106,7 @@ export class ClusterOverviewStore extends KubeObjectStore<Cluster, ClusterApi> i
@action
resetMetrics() {
this.metrics = {};
this.metricsLoaded = false;
this.metrics = undefined;
}
reset() {
@@ -23,6 +23,7 @@ import clusterOverviewStoreInjectable from "./cluster-overview-store/cluster-ove
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"}`);
@@ -48,16 +49,32 @@ const NonInjectedClusterPieCharts = observer(({
);
};
const renderCharts = () => {
const data = getMetricLastPoints(clusterOverviewStore.metrics);
const { memoryUsage, memoryRequests, memoryAllocatableCapacity, memoryCapacity, memoryLimits } = data;
const { cpuUsage, cpuRequests, cpuAllocatableCapacity, cpuCapacity, cpuLimits } = data;
const { podUsage, podAllocatableCapacity, podCapacity } = data;
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 cpuLimits !== "number" ||
typeof podCapacity !== "number" ||
typeof podAllocatableCapacity !== "number" ||
typeof memoryAllocatableCapacity !== "number" ||
typeof memoryCapacity !== "number" ||
typeof memoryLimits !== "number" ||
typeof memoryUsage !== "number" ||
typeof memoryRequests !== "number"
) {
return null;
}
const cpuLimitsOverload = cpuLimits > cpuAllocatableCapacity;
const memoryLimitsOverload = memoryLimits > memoryAllocatableCapacity;
const defaultColor = activeTheme.get().colors.pieChartDefaultColor;
if (!memoryCapacity || !cpuCapacity || !podCapacity || !memoryAllocatableCapacity || !cpuAllocatableCapacity || !podAllocatableCapacity) return null;
const cpuData: PieChartData = {
datasets: [
{
@@ -218,7 +235,7 @@ const NonInjectedClusterPieCharts = observer(({
);
};
const renderContent = ({ metricNodeRole, metricsLoaded }: ClusterOverviewStore) => {
const renderContent = ({ metricNodeRole, metrics }: ClusterOverviewStore) => {
const { masterNodes, workerNodes } = nodeStore;
const nodes = metricNodeRole === MetricNodeRole.MASTER ? masterNodes : workerNodes;
@@ -231,14 +248,16 @@ const NonInjectedClusterPieCharts = observer(({
);
}
if (!metricsLoaded) {
if (!metrics) {
return (
<div className={cssNames(styles.empty, "flex justify-center align-center box grow")}>
<Spinner/>
</div>
);
}
const { memoryCapacity, cpuCapacity, podCapacity } = getMetricLastPoints(clusterOverviewStore.metrics);
const lastPoints = getMetricLastPoints(metrics);
const { memoryCapacity, cpuCapacity, podCapacity } = lastPoints;
if (!memoryCapacity || !cpuCapacity || !podCapacity) {
return (
@@ -248,7 +267,7 @@ const NonInjectedClusterPieCharts = observer(({
);
}
return renderCharts();
return renderCharts(lastPoints);
};
return (
@@ -5,14 +5,11 @@
import "@testing-library/jest-dom/extend-expect";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import fse from "fs-extra";
import React from "react";
import type { ExtensionDiscovery } from "../../../../extensions/extension-discovery/extension-discovery";
import type { ExtensionLoader } from "../../../../extensions/extension-loader";
import { ConfirmDialog } from "../../confirm-dialog";
import { Extensions } from "../extensions";
import mockFs from "mock-fs";
import { mockWindow } from "../../../../../__mocks__/windowMock";
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable";
import type { DiRender } from "../../test-utils/renderFor";
@@ -20,18 +17,15 @@ import { renderFor } from "../../test-utils/renderFor";
import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable";
import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import directoryForDownloadsInjectable from "../../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable";
import getConfigurationFileModelInjectable from "../../../../common/get-configuration-file-model/get-configuration-file-model.injectable";
import assert from "assert";
import type { InstallFromInput } from "../install-from-input/install-from-input";
import installFromInputInjectable from "../install-from-input/install-from-input.injectable";
import type { InstallExtensionFromInput } from "../install-extension-from-input.injectable";
import installExtensionFromInputInjectable from "../install-extension-from-input.injectable";
import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store";
import extensionInstallationStateStoreInjectable from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import { observable, when } from "mobx";
import type { DeleteFile } from "../../../../common/fs/delete-file.injectable";
import deleteFileInjectable from "../../../../common/fs/delete-file.injectable";
mockWindow();
jest.setTimeout(30000);
jest.mock("fs-extra");
jest.mock("../../notifications");
jest.mock("../../../../common/utils/downloadFile", () => ({
@@ -52,9 +46,10 @@ jest.mock("../../../../common/utils/tar");
describe("Extensions", () => {
let extensionLoader: ExtensionLoader;
let extensionDiscovery: ExtensionDiscovery;
let installFromInput: jest.MockedFunction<InstallFromInput>;
let installExtensionFromInput: jest.MockedFunction<InstallExtensionFromInput>;
let extensionInstallationStateStore: ExtensionInstallationStateStore;
let render: DiRender;
let deleteFileMock: jest.MockedFunction<DeleteFile>;
beforeEach(() => {
const di = getDiForUnitTesting({ doGeneralOverrides: true });
@@ -62,17 +57,13 @@ describe("Extensions", () => {
di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data");
di.override(directoryForDownloadsInjectable, () => "some-directory-for-downloads");
di.permitSideEffects(getConfigurationFileModelInjectable);
mockFs({
"some-directory-for-user-data": {},
});
render = renderFor(di);
installFromInput = jest.fn();
installExtensionFromInput = jest.fn();
di.override(installExtensionFromInputInjectable, () => installExtensionFromInput);
di.override(installFromInputInjectable, () => installFromInput);
deleteFileMock = jest.fn();
di.override(deleteFileInjectable, () => deleteFileMock);
extensionLoader = di.inject(extensionLoaderInjectable);
extensionDiscovery = di.inject(extensionDiscoveryInjectable);
@@ -95,10 +86,6 @@ describe("Extensions", () => {
extensionDiscovery.uninstallExtension = jest.fn(() => Promise.resolve());
});
afterEach(() => {
mockFs.restore();
});
it("disables uninstall and disable buttons while uninstalling", async () => {
extensionDiscovery.isLoaded = true;
@@ -138,8 +125,8 @@ describe("Extensions", () => {
const resolveInstall = observable.box(false);
(fse.unlink as jest.MockedFunction<typeof fse.unlink>).mockReturnValue(Promise.resolve());
installFromInput.mockImplementation(async (input) => {
deleteFileMock.mockReturnValue(Promise.resolve());
installExtensionFromInput.mockImplementation(async (input) => {
expect(input).toBe("https://test.extensionurl/package.tgz");
const clear = extensionInstallationStateStore.startPreInstall();
@@ -2,22 +2,18 @@
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { ExtendableDisposer } from "../../../common/utils";
import { downloadFile, downloadJson } from "../../../common/utils";
import { Notifications } from "../notifications";
import React from "react";
import path from "path";
import { SemVer } from "semver";
import URLParse from "url-parse";
import type { InstallRequest } from "./attempt-install/install-request";
import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store";
import type { Confirm } from "../confirm-dialog/confirm.injectable";
import { getInjectable } from "@ogre-tools/injectable";
import attemptInstallInjectable from "./attempt-install/attempt-install.injectable";
import getBaseRegistryUrlInjectable from "./get-base-registry-url/get-base-registry-url.injectable";
import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import confirmInjectable from "../confirm-dialog/confirm.injectable";
import { reduce } from "lodash";
import getBasenameOfPathInjectable from "../../../common/path/get-basename.injectable";
export interface ExtensionInfo {
name: string;
@@ -27,126 +23,113 @@ export interface ExtensionInfo {
export type AttemptInstallByInfo = (info: ExtensionInfo) => Promise<void>;
interface Dependencies {
attemptInstall: (request: InstallRequest, d: ExtendableDisposer) => Promise<void>;
getBaseRegistryUrl: () => Promise<string>;
extensionInstallationStateStore: ExtensionInstallationStateStore;
confirm: Confirm;
}
const attemptInstallByInfoInjectable = getInjectable({
id: "attempt-install-by-info",
instantiate: (di): AttemptInstallByInfo => {
const attemptInstall = di.inject(attemptInstallInjectable);
const getBaseRegistryUrl = di.inject(getBaseRegistryUrlInjectable);
const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
const confirm = di.inject(confirmInjectable);
const getBasenameOfPath = di.inject(getBasenameOfPathInjectable);
const attemptInstallByInfo = ({
attemptInstall,
getBaseRegistryUrl,
extensionInstallationStateStore,
confirm,
}: Dependencies): AttemptInstallByInfo => (
async (info) => {
const { name, version, requireConfirmation = false } = info;
const disposer = extensionInstallationStateStore.startPreInstall();
const baseUrl = await getBaseRegistryUrl();
const registryUrl = new URLParse(baseUrl).set("pathname", name).toString();
let json: any;
let finalVersion = version;
return async (info) => {
const { name, version, requireConfirmation = false } = info;
const disposer = extensionInstallationStateStore.startPreInstall();
const baseUrl = await getBaseRegistryUrl();
const registryUrl = new URLParse(baseUrl).set("pathname", name).toString();
let json: any;
let finalVersion = version;
try {
json = await downloadJson({ url: registryUrl }).promise;
try {
json = await downloadJson({ url: registryUrl }).promise;
if (!json || json.error || typeof json.versions !== "object" || !json.versions) {
const message = json?.error ? `: ${json.error}` : "";
if (!json || json.error || typeof json.versions !== "object" || !json.versions) {
const message = json?.error ? `: ${json.error}` : "";
Notifications.error(`Failed to get registry information for that extension${message}`);
return disposer();
}
} catch (error) {
if (error instanceof SyntaxError) {
// assume invalid JSON
console.warn("Set registry has invalid json", { url: baseUrl }, error);
Notifications.error("Failed to get valid registry information for that extension. Registry did not return valid JSON");
} else {
console.error("Failed to download registry information", error);
Notifications.error(`Failed to get valid registry information for that extension. ${error}`);
}
return disposer();
}
if (version) {
if (!json.versions[version]) {
if (json["dist-tags"][version]) {
finalVersion = json["dist-tags"][version];
} else {
Notifications.error((
<p>
{"The "}
<em>{name}</em>
{" extension does not have a version or tag "}
<code>{version}</code>
.
</p>
));
Notifications.error(`Failed to get registry information for that extension${message}`);
return disposer();
}
} catch (error) {
if (error instanceof SyntaxError) {
// assume invalid JSON
console.warn("Set registry has invalid json", { url: baseUrl }, error);
Notifications.error("Failed to get valid registry information for that extension. Registry did not return valid JSON");
} else {
console.error("Failed to download registry information", error);
Notifications.error(`Failed to get valid registry information for that extension. ${error}`);
}
return disposer();
}
} else {
const versions = Object.keys(json.versions)
.map(version => new SemVer(version, { loose: true }))
if (version) {
if (!json.versions[version]) {
if (json["dist-tags"][version]) {
finalVersion = json["dist-tags"][version];
} else {
Notifications.error((
<p>
{"The "}
<em>{name}</em>
{" extension does not have a version or tag "}
<code>{version}</code>
.
</p>
));
return disposer();
}
}
} else {
const versions = Object.keys(json.versions)
.map(version => new SemVer(version, { loose: true }))
// ignore pre-releases for auto picking the version
.filter(version => version.prerelease.length === 0);
.filter(version => version.prerelease.length === 0);
const latestVersion = reduce(versions, (prev, curr) => prev.compareMain(curr) === -1 ? curr : prev);
const latestVersion = reduce(versions, (prev, curr) => prev.compareMain(curr) === -1 ? curr : prev);
if (!latestVersion) {
console.error("No versions supplied for that extension", { name });
Notifications.error(`No versions found for ${name}`);
if (!latestVersion) {
console.error("No versions supplied for that extension", { name });
Notifications.error(`No versions found for ${name}`);
return disposer();
return disposer();
}
finalVersion = latestVersion.format();
}
finalVersion = latestVersion.format();
}
if (requireConfirmation) {
const proceed = await confirm({
message: (
<p>
Are you sure you want to install
{" "}
<b>
{name}
@
{finalVersion}
</b>
?
</p>
),
labelCancel: "Cancel",
labelOk: "Install",
});
if (requireConfirmation) {
const proceed = await confirm({
message: (
<p>
Are you sure you want to install
{" "}
<b>
{name}
@
{finalVersion}
</b>
?
</p>
),
labelCancel: "Cancel",
labelOk: "Install",
});
if (!proceed) {
return disposer();
if (!proceed) {
return disposer();
}
}
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const url = json.versions[finalVersion!].dist.tarball;
const fileName = path.basename(url);
const { promise: dataP } = downloadFile({ url, timeout: 10 * 60 * 1000 });
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const url = json.versions[finalVersion!].dist.tarball;
const fileName = getBasenameOfPath(url);
const { promise: dataP } = downloadFile({ url, timeout: 10 * 60 * 1000 });
return attemptInstall({ fileName, dataP }, disposer);
}
);
const attemptInstallByInfoInjectable = getInjectable({
id: "attempt-install-by-info",
instantiate: (di) => attemptInstallByInfo({
attemptInstall: di.inject(attemptInstallInjectable),
getBaseRegistryUrl: di.inject(getBaseRegistryUrlInjectable),
extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable),
confirm: di.inject(confirmInjectable),
}),
return attemptInstall({ fileName, dataP }, disposer);
};
},
});
export default attemptInstallByInfoInjectable;
@@ -2,12 +2,8 @@
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type {
Disposer,
ExtendableDisposer } from "../../../../common/utils";
import {
disposer,
} from "../../../../common/utils";
import type { ExtendableDisposer } from "../../../../common/utils";
import { disposer } from "../../../../common/utils";
import { Notifications } from "../../notifications";
import { Button } from "../../button";
import type { ExtensionLoader } from "../../../../extensions/extension-loader";
@@ -15,29 +11,19 @@ import type { LensExtensionId } from "../../../../extensions/lens-extension";
import React from "react";
import { remove as removeDir } from "fs-extra";
import { shell } from "electron";
import type { InstallRequestValidated } from "./create-temp-files-and-validate/create-temp-files-and-validate";
import type { InstallRequest } from "./install-request";
import type {
ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store";
import {
ExtensionInstallationState,
} from "../../../../extensions/extension-installation-state-store/extension-installation-state-store";
import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store";
import { ExtensionInstallationState } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store";
import type { UnpackExtension } from "./unpack-extension/unpack-extension.injectable";
import type { CreateTempFilesAndValidate } from "./create-temp-files-and-validate/create-temp-files-and-validate.injectable";
import type { GetExtensionDestFolder } from "./get-extension-dest-folder/get-extension-dest-folder.injectable";
interface Dependencies {
extensionLoader: ExtensionLoader;
uninstallExtension: (id: LensExtensionId) => Promise<boolean>;
unpackExtension: (
request: InstallRequestValidated,
disposeDownloading: Disposer,
) => Promise<void>;
createTempFilesAndValidate: (
installRequest: InstallRequest,
) => Promise<InstallRequestValidated | null>;
getExtensionDestFolder: (name: string) => string;
unpackExtension: UnpackExtension;
createTempFilesAndValidate: CreateTempFilesAndValidate;
getExtensionDestFolder: GetExtensionDestFolder;
extensionInstallationStateStore: ExtensionInstallationStateStore;
}
@@ -51,6 +37,8 @@ export const attemptInstall =
extensionInstallationStateStore,
}: Dependencies) =>
async (request: InstallRequest, d?: ExtendableDisposer): Promise<void> => {
console.log("Attempting to install extension");
const dispose = disposer(
extensionInstallationStateStore.startPreInstall(),
d,
@@ -3,16 +3,102 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { createTempFilesAndValidate } from "./create-temp-files-and-validate";
import extensionDiscoveryInjectable from "../../../../../extensions/extension-discovery/extension-discovery.injectable";
import React from "react";
import type { LensExtensionId, LensExtensionManifest } from "../../../../../extensions/lens-extension";
import { getMessageFromError } from "../../get-message-from-error/get-message-from-error";
import type { InstallRequest } from "../install-request";
import { validatePackage } from "../validate-package/validate-package";
import joinPathsInjectable from "../../../../../common/path/join-paths.injectable";
import tempDirectoryPathInjectable from "../../../../../common/os/temp-directory-path.injectable";
import ensureDirInjectable from "../../../../../common/fs/ensure-dir.injectable";
import writeFileInjectable from "../../../../../common/fs/write-file.injectable";
import loggerInjectable from "../../../../../common/logger.injectable";
import showErrorNotificationInjectable from "../../../notifications/show-error-notification.injectable";
export interface InstallRequestValidated {
fileName: string;
data: Buffer;
id: LensExtensionId;
manifest: LensExtensionManifest;
tempFile: string; // temp system path to packed extension for unpacking
}
export type CreateTempFilesAndValidate = (req: InstallRequest) => Promise<InstallRequestValidated | null>;
const createTempFilesAndValidateInjectable = getInjectable({
id: "create-temp-files-and-validate",
instantiate: (di) =>
createTempFilesAndValidate({
extensionDiscovery: di.inject(extensionDiscoveryInjectable),
}),
instantiate: (di) => {
const extensionDiscovery = di.inject(extensionDiscoveryInjectable);
const joinPaths = di.inject(joinPathsInjectable);
const tempDirectoryPath = di.inject(tempDirectoryPathInjectable);
const ensureDir = di.inject(ensureDirInjectable);
const writeFile = di.inject(writeFileInjectable);
const logger = di.inject(loggerInjectable);
const showErrorNotification = di.inject(showErrorNotificationInjectable);
const baseTempExtensionsDirectory = joinPaths(tempDirectoryPath, "lens-extensions");
const getExtensionPackageTemp = (fileName: string) => joinPaths(baseTempExtensionsDirectory, fileName);
return async ({
fileName,
dataP,
}: InstallRequest): Promise<InstallRequestValidated | null> => {
// copy files to temp
await ensureDir(baseTempExtensionsDirectory);
// validate packages
const tempFile = getExtensionPackageTemp(fileName);
try {
const data = await dataP;
if (!data) {
return null;
}
await writeFile(tempFile, data);
logger.info("validating package", tempFile);
const manifest = await validatePackage(tempFile);
const id = joinPaths(
extensionDiscovery.nodeModulesPath,
manifest.name,
"package.json",
);
return {
fileName,
data,
manifest,
tempFile,
id,
};
} catch (error) {
const message = getMessageFromError(error);
logger.info(
`[EXTENSION-INSTALLATION]: installing ${fileName} has failed: ${message}`,
{ error },
);
showErrorNotification(
<div className="flex column gaps">
<p>
{"Installing "}
<em>{fileName}</em>
{" has failed, skipping."}
</p>
<p>
{"Reason: "}
<em>{message}</em>
</p>
</div>,
);
}
return null;
};
},
});
export default createTempFilesAndValidateInjectable;
@@ -1,94 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { validatePackage } from "../validate-package/validate-package";
import type { ExtensionDiscovery } from "../../../../../extensions/extension-discovery/extension-discovery";
import { getMessageFromError } from "../../get-message-from-error/get-message-from-error";
import logger from "../../../../../main/logger";
import { Notifications } from "../../../notifications";
import path from "path";
import fse from "fs-extra";
import React from "react";
import os from "os";
import type {
LensExtensionId,
LensExtensionManifest,
} from "../../../../../extensions/lens-extension";
import type { InstallRequest } from "../install-request";
export interface InstallRequestValidated {
fileName: string;
data: Buffer;
id: LensExtensionId;
manifest: LensExtensionManifest;
tempFile: string; // temp system path to packed extension for unpacking
}
interface Dependencies {
extensionDiscovery: ExtensionDiscovery;
}
export const createTempFilesAndValidate =
({ extensionDiscovery }: Dependencies) =>
async ({
fileName,
dataP,
}: InstallRequest): Promise<InstallRequestValidated | null> => {
// copy files to temp
await fse.ensureDir(getExtensionPackageTemp());
// validate packages
const tempFile = getExtensionPackageTemp(fileName);
try {
const data = await dataP;
if (!data) {
return null;
}
await fse.writeFile(tempFile, data);
const manifest = await validatePackage(tempFile);
const id = path.join(
extensionDiscovery.nodeModulesPath,
manifest.name,
"package.json",
);
return {
fileName,
data,
manifest,
tempFile,
id,
};
} catch (error) {
const message = getMessageFromError(error);
logger.info(
`[EXTENSION-INSTALLATION]: installing ${fileName} has failed: ${message}`,
{ error },
);
Notifications.error(
<div className="flex column gaps">
<p>
{"Installing "}
<em>{fileName}</em>
{" has failed, skipping."}
</p>
<p>
{"Reason: "}
<em>{message}</em>
</p>
</div>,
);
}
return null;
};
function getExtensionPackageTemp(fileName = "") {
return path.join(os.tmpdir(), "lens-extensions", fileName);
}
@@ -3,18 +3,21 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import joinPathsInjectable from "../../../../../common/path/join-paths.injectable";
import extensionDiscoveryInjectable from "../../../../../extensions/extension-discovery/extension-discovery.injectable";
import { sanitizeExtensionName } from "../../../../../extensions/lens-extension";
import { getExtensionDestFolder } from "./get-extension-dest-folder";
export type GetExtensionDestFolder = (extensionName: string) => string;
const getExtensionDestFolderInjectable = getInjectable({
id: "get-extension-dest-folder",
instantiate: (di) =>
getExtensionDestFolder({
extensionDiscovery: di.inject(extensionDiscoveryInjectable),
}),
instantiate: (di): GetExtensionDestFolder => {
const extensionDiscovery = di.inject(extensionDiscoveryInjectable);
const joinPaths = di.inject(joinPathsInjectable);
return (name) => joinPaths(extensionDiscovery.localFolderPath, sanitizeExtensionName(name));
},
});
export default getExtensionDestFolderInjectable;
@@ -1,16 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { ExtensionDiscovery } from "../../../../../extensions/extension-discovery/extension-discovery";
import { sanitizeExtensionName } from "../../../../../extensions/lens-extension";
import path from "path";
interface Dependencies {
extensionDiscovery: ExtensionDiscovery;
}
export const getExtensionDestFolder =
({ extensionDiscovery }: Dependencies) =>
(name: string) =>
path.join(extensionDiscovery.localFolderPath, sanitizeExtensionName(name));
@@ -2,23 +2,129 @@
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import React from "react";
import { getInjectable } from "@ogre-tools/injectable";
import { unpackExtension } from "./unpack-extension";
import extensionLoaderInjectable from "../../../../../extensions/extension-loader/extension-loader.injectable";
import getExtensionDestFolderInjectable
from "../get-extension-dest-folder/get-extension-dest-folder.injectable";
import extensionInstallationStateStoreInjectable
from "../../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import getExtensionDestFolderInjectable from "../get-extension-dest-folder/get-extension-dest-folder.injectable";
import extensionInstallationStateStoreInjectable from "../../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import type { InstallRequestValidated } from "../create-temp-files-and-validate/create-temp-files-and-validate.injectable";
import type { Disposer } from "../../../../utils";
import { noop } from "../../../../utils";
import { extensionDisplayName } from "../../../../../extensions/lens-extension";
import joinPathsInjectable from "../../../../../common/path/join-paths.injectable";
import loggerInjectable from "../../../../../common/logger.injectable";
import { when } from "mobx";
import { getMessageFromError } from "../../get-message-from-error/get-message-from-error";
import showSuccessNotificationInjectable from "../../../notifications/show-success-notification.injectable";
import showErrorNotificationInjectable from "../../../notifications/show-error-notification.injectable";
import getDirnameOfPathInjectable from "../../../../../common/path/get-dirname.injectable";
import getBasenameOfPathInjectable from "../../../../../common/path/get-basename.injectable";
import extractTarInjectable from "../../../../../common/fs/extract-tar.injectable";
import ensureDirInjectable from "../../../../../common/fs/ensure-dir.injectable";
import removePathInjectable from "../../../../../common/fs/remove-path.injectable";
import deleteFileInjectable from "../../../../../common/fs/delete-file.injectable";
import readDirectoryInjectable from "../../../../../common/fs/read-directory.injectable";
import moveInjectable from "../../../../../common/fs/move.injectable";
export type UnpackExtension = (request: InstallRequestValidated, disposeDownloading?: Disposer) => Promise<void>;
const unpackExtensionInjectable = getInjectable({
id: "unpack-extension",
instantiate: (di) =>
unpackExtension({
extensionLoader: di.inject(extensionLoaderInjectable),
getExtensionDestFolder: di.inject(getExtensionDestFolderInjectable),
extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable),
}),
instantiate: (di): UnpackExtension => {
const extensionLoader = di.inject(extensionLoaderInjectable);
const getExtensionDestFolder = di.inject(getExtensionDestFolderInjectable);
const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
const joinPaths = di.inject(joinPathsInjectable);
const getDirnameOfPath = di.inject(getDirnameOfPathInjectable);
const getBasenameOfPath = di.inject(getBasenameOfPathInjectable);
const logger = di.inject(loggerInjectable);
const showOkNotification = di.inject(showSuccessNotificationInjectable);
const showErrorNotification = di.inject(showErrorNotificationInjectable);
const extractTar = di.inject(extractTarInjectable);
const ensureDir = di.inject(ensureDirInjectable);
const removePath = di.inject(removePathInjectable);
const deleteFile = di.inject(deleteFileInjectable);
const readDirectory = di.inject(readDirectoryInjectable);
const move = di.inject(moveInjectable);
return async (request, disposeDownloading) => {
const {
id,
fileName,
tempFile,
manifest: { name, version },
} = request;
extensionInstallationStateStore.setInstalling(id);
disposeDownloading?.();
const displayName = extensionDisplayName(name, version);
const extensionFolder = getExtensionDestFolder(name);
const unpackingTempFolder = joinPaths(
getDirnameOfPath(tempFile),
`${getBasenameOfPath(tempFile)}-unpacked`,
);
logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile });
try {
// extract to temp folder first
await removePath(unpackingTempFolder).catch(noop);
await ensureDir(unpackingTempFolder);
await extractTar(tempFile, { cwd: unpackingTempFolder });
// move contents to extensions folder
const unpackedFiles = await readDirectory(unpackingTempFolder);
let unpackedRootFolder = unpackingTempFolder;
if (unpackedFiles.length === 1) {
// check if %extension.tgz was packed with single top folder,
// e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball
unpackedRootFolder = joinPaths(unpackingTempFolder, unpackedFiles[0]);
}
await ensureDir(extensionFolder);
await move(unpackedRootFolder, extensionFolder, { overwrite: true });
// wait for the loader has actually install it
await when(() => extensionLoader.userExtensions.has(id));
// Enable installed extensions by default.
extensionLoader.setIsEnabled(id, true);
showOkNotification(
<p>
{"Extension "}
<b>{displayName}</b>
{" successfully installed!"}
</p>,
);
} catch (error) {
const message = getMessageFromError(error);
logger.info(
`[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`,
{ error },
);
showErrorNotification(
<p>
{"Installing extension "}
<b>{displayName}</b>
{" has failed: "}
<em>{message}</em>
</p>,
);
} finally {
// Remove install state once finished
extensionInstallationStateStore.clearInstalling(id);
// clean up
removePath(unpackingTempFolder).catch(noop);
deleteFile(tempFile).catch(noop);
}
};
},
});
export default unpackExtensionInjectable;
@@ -1,106 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { InstallRequestValidated } from "../create-temp-files-and-validate/create-temp-files-and-validate";
import type { Disposer } from "../../../../../common/utils";
import { extractTar, noop } from "../../../../../common/utils";
import { extensionDisplayName } from "../../../../../extensions/lens-extension";
import logger from "../../../../../main/logger";
import type { ExtensionLoader } from "../../../../../extensions/extension-loader";
import { Notifications } from "../../../notifications";
import { getMessageFromError } from "../../get-message-from-error/get-message-from-error";
import path from "path";
import fse from "fs-extra";
import { when } from "mobx";
import React from "react";
import type { ExtensionInstallationStateStore } from "../../../../../extensions/extension-installation-state-store/extension-installation-state-store";
interface Dependencies {
extensionLoader: ExtensionLoader;
getExtensionDestFolder: (name: string) => string;
extensionInstallationStateStore: ExtensionInstallationStateStore;
}
export const unpackExtension =
({
extensionLoader,
getExtensionDestFolder,
extensionInstallationStateStore,
}: Dependencies) =>
async (request: InstallRequestValidated, disposeDownloading?: Disposer) => {
const {
id,
fileName,
tempFile,
manifest: { name, version },
} = request;
extensionInstallationStateStore.setInstalling(id);
disposeDownloading?.();
const displayName = extensionDisplayName(name, version);
const extensionFolder = getExtensionDestFolder(name);
const unpackingTempFolder = path.join(
path.dirname(tempFile),
`${path.basename(tempFile)}-unpacked`,
);
logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile });
try {
// extract to temp folder first
await fse.remove(unpackingTempFolder).catch(noop);
await fse.ensureDir(unpackingTempFolder);
await extractTar(tempFile, { cwd: unpackingTempFolder });
// move contents to extensions folder
const unpackedFiles = await fse.readdir(unpackingTempFolder);
let unpackedRootFolder = unpackingTempFolder;
if (unpackedFiles.length === 1) {
// check if %extension.tgz was packed with single top folder,
// e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball
unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]);
}
await fse.ensureDir(extensionFolder);
await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true });
// wait for the loader has actually install it
await when(() => extensionLoader.userExtensions.has(id));
// Enable installed extensions by default.
extensionLoader.setIsEnabled(id, true);
Notifications.ok(
<p>
{"Extension "}
<b>{displayName}</b>
{" successfully installed!"}
</p>,
);
} catch (error) {
const message = getMessageFromError(error);
logger.info(
`[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`,
{ error },
);
Notifications.error(
<p>
{"Installing extension "}
<b>{displayName}</b>
{" has failed: "}
<em>{message}</em>
</p>,
);
} finally {
// Remove install state once finished
extensionInstallationStateStore.clearInstalling(id);
// clean up
fse.remove(unpackingTempFolder).catch(noop);
fse.unlink(tempFile).catch(noop);
}
};
@@ -3,16 +3,29 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { attemptInstalls } from "./attempt-installs";
import getBasenameOfPathInjectable from "../../../../common/path/get-basename.injectable";
import attemptInstallInjectable from "../attempt-install/attempt-install.injectable";
import readFileNotifyInjectable from "../read-file-notify/read-file-notify.injectable";
export type AttemptInstalls = (filePaths: string[]) => Promise<void>;
const attemptInstallsInjectable = getInjectable({
id: "attempt-installs",
instantiate: (di) =>
attemptInstalls({
attemptInstall: di.inject(attemptInstallInjectable),
}),
instantiate: (di): AttemptInstalls => {
const attemptInstall = di.inject(attemptInstallInjectable);
const getBasenameOfPath = di.inject(getBasenameOfPathInjectable);
const readFileNotify = di.inject(readFileNotifyInjectable);
return async (filePaths) => {
await Promise.allSettled(filePaths.map(filePath => (
attemptInstall({
fileName: getBasenameOfPath(filePath),
dataP: readFileNotify(filePath),
})
)));
};
},
});
export default attemptInstallsInjectable;
@@ -1,28 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { readFileNotify } from "../read-file-notify/read-file-notify";
import path from "path";
import type { InstallRequest } from "../attempt-install/install-request";
interface Dependencies {
attemptInstall: (request: InstallRequest) => Promise<void>;
}
export const attemptInstalls =
({ attemptInstall }: Dependencies) =>
async (filePaths: string[]): Promise<void> => {
const promises: Promise<void>[] = [];
for (const filePath of filePaths) {
promises.push(
attemptInstall({
fileName: path.basename(filePath),
dataP: readFileNotify(filePath),
}),
);
}
await Promise.allSettled(promises);
};
@@ -28,21 +28,21 @@ import enableExtensionInjectable from "./enable-extension/enable-extension.injec
import disableExtensionInjectable from "./disable-extension/disable-extension.injectable";
import type { ConfirmUninstallExtension } from "./confirm-uninstall-extension.injectable";
import confirmUninstallExtensionInjectable from "./confirm-uninstall-extension.injectable";
import installFromInputInjectable from "./install-from-input/install-from-input.injectable";
import type { InstallExtensionFromInput } from "./install-extension-from-input.injectable";
import installExtensionFromInputInjectable from "./install-extension-from-input.injectable";
import installFromSelectFileDialogInjectable from "./install-from-select-file-dialog.injectable";
import type { LensExtensionId } from "../../../extensions/lens-extension";
import installOnDropInjectable from "./install-on-drop/install-on-drop.injectable";
import { supportedExtensionFormats } from "./supported-extension-formats";
import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store";
import type { InstallFromInput } from "./install-from-input/install-from-input";
interface Dependencies {
userExtensions: IComputedValue<InstalledExtension[]>;
enableExtension: (id: LensExtensionId) => void;
disableExtension: (id: LensExtensionId) => void;
confirmUninstallExtension: ConfirmUninstallExtension;
installFromInput: InstallFromInput;
installExtensionFromInput: InstallExtensionFromInput;
installFromSelectFileDialog: () => Promise<void>;
installOnDrop: (files: File[]) => Promise<void>;
extensionInstallationStateStore: ExtensionInstallationStateStore;
@@ -107,7 +107,7 @@ class NonInjectedExtensions extends React.Component<Dependencies> {
<Install
supportedFormats={supportedExtensionFormats}
onChange={value => (this.installPath = value)}
installFromInput={() => this.props.installFromInput(this.installPath)}
installFromInput={() => this.props.installExtensionFromInput(this.installPath)}
installFromSelectFileDialog={this.props.installFromSelectFileDialog}
installPath={this.installPath}
/>
@@ -131,7 +131,7 @@ export const Extensions = withInjectables<Dependencies>(NonInjectedExtensions, {
enableExtension: di.inject(enableExtensionInjectable),
disableExtension: di.inject(disableExtensionInjectable),
confirmUninstallExtension: di.inject(confirmUninstallExtensionInjectable),
installFromInput: di.inject(installFromInputInjectable),
installExtensionFromInput: di.inject(installExtensionFromInputInjectable),
installOnDrop: di.inject(installOnDropInjectable),
installFromSelectFileDialog: di.inject(installFromSelectFileDialogInjectable),
extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable),
@@ -0,0 +1,82 @@
/**
* 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 { ExtendableDisposer } from "../../../common/utils";
import { downloadFile } from "../../../common/utils";
import { InputValidators } from "../input";
import { getMessageFromError } from "./get-message-from-error/get-message-from-error";
import { getInjectable } from "@ogre-tools/injectable";
import attemptInstallInjectable from "./attempt-install/attempt-install.injectable";
import attemptInstallByInfoInjectable from "./attempt-install-by-info.injectable";
import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import readFileNotifyInjectable from "./read-file-notify/read-file-notify.injectable";
import getBasenameOfPathInjectable from "../../../common/path/get-basename.injectable";
import showErrorNotificationInjectable from "../notifications/show-error-notification.injectable";
import loggerInjectable from "../../../common/logger.injectable";
export type InstallExtensionFromInput = (input: string) => Promise<void>;
const installExtensionFromInputInjectable = getInjectable({
id: "install-extension-from-input",
instantiate: (di): InstallExtensionFromInput => {
const attemptInstall = di.inject(attemptInstallInjectable);
const attemptInstallByInfo = di.inject(attemptInstallByInfoInjectable);
const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
const readFileNotify = di.inject(readFileNotifyInjectable);
const getBasenameOfPath = di.inject(getBasenameOfPathInjectable);
const showErrorNotification = di.inject(showErrorNotificationInjectable);
const logger = di.inject(loggerInjectable);
return async (input) => {
let disposer: ExtendableDisposer | undefined = undefined;
try {
// fixme: improve error messages for non-tar-file URLs
if (InputValidators.isUrl.validate(input)) {
// install via url
disposer = extensionInstallationStateStore.startPreInstall();
const { promise } = downloadFile({ url: input, timeout: 10 * 60 * 1000 });
const fileName = getBasenameOfPath(input);
return await attemptInstall({ fileName, dataP: promise }, disposer);
}
try {
await InputValidators.isPath.validate(input);
// install from system path
const fileName = getBasenameOfPath(input);
return await attemptInstall({ fileName, dataP: readFileNotify(input) });
} catch (error) {
const extNameCaptures = InputValidators.isExtensionNameInstallRegex.captures(input);
if (extNameCaptures) {
const { name, version } = extNameCaptures;
return await attemptInstallByInfo({ name, version });
}
}
throw new Error(`Unknown format of input: ${input}`);
} catch (error) {
const message = getMessageFromError(error);
logger.info(`[EXTENSION-INSTALL]: installation has failed: ${message}`, { error, installPath: input });
showErrorNotification((
<p>
{"Installation has failed: "}
<b>{message}</b>
</p>
));
} finally {
disposer?.();
}
};
},
});
export default installExtensionFromInputInjectable;
@@ -1,23 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import attemptInstallInjectable from "../attempt-install/attempt-install.injectable";
import { installFromInput } from "./install-from-input";
import attemptInstallByInfoInjectable from "../attempt-install-by-info.injectable";
import extensionInstallationStateStoreInjectable
from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
const installFromInputInjectable = getInjectable({
id: "install-extension-from-input",
instantiate: (di) =>
installFromInput({
attemptInstall: di.inject(attemptInstallInjectable),
attemptInstallByInfo: di.inject(attemptInstallByInfoInjectable),
extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable),
}),
});
export default installFromInputInjectable;
@@ -1,77 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { ExtendableDisposer } from "../../../../common/utils";
import { downloadFile } from "../../../../common/utils";
import { InputValidators } from "../../input";
import { getMessageFromError } from "../get-message-from-error/get-message-from-error";
import logger from "../../../../main/logger";
import { Notifications } from "../../notifications";
import path from "path";
import React from "react";
import { readFileNotify } from "../read-file-notify/read-file-notify";
import type { InstallRequest } from "../attempt-install/install-request";
import type { ExtensionInfo } from "../attempt-install-by-info.injectable";
import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store";
export type InstallFromInput = (input: string) => Promise<void>;
interface Dependencies {
attemptInstall: (request: InstallRequest, disposer?: ExtendableDisposer) => Promise<void>;
attemptInstallByInfo: (extensionInfo: ExtensionInfo) => Promise<void>;
extensionInstallationStateStore: ExtensionInstallationStateStore;
}
export const installFromInput = ({
attemptInstall,
attemptInstallByInfo,
extensionInstallationStateStore,
}: Dependencies): InstallFromInput => (
async (input) => {
let disposer: ExtendableDisposer | undefined = undefined;
try {
// fixme: improve error messages for non-tar-file URLs
if (InputValidators.isUrl.validate(input)) {
// install via url
disposer = extensionInstallationStateStore.startPreInstall();
const { promise } = downloadFile({ url: input, timeout: 10 * 60 * 1000 });
const fileName = path.basename(input);
return await attemptInstall({ fileName, dataP: promise }, disposer);
}
try {
await InputValidators.isPath.validate(input);
// install from system path
const fileName = path.basename(input);
return await attemptInstall({ fileName, dataP: readFileNotify(input) });
} catch (error) {
const extNameCaptures = InputValidators.isExtensionNameInstallRegex.captures(input);
if (extNameCaptures) {
const { name, version } = extNameCaptures;
return await attemptInstallByInfo({ name, version });
}
}
throw new Error(`Unknown format of input: ${input}`);
} catch (error) {
const message = getMessageFromError(error);
logger.info(`[EXTENSION-INSTALL]: installation has failed: ${message}`, { error, installPath: input });
Notifications.error((
<p>
{"Installation has failed: "}
<b>{message}</b>
</p>
));
} finally {
disposer?.();
}
}
);
@@ -0,0 +1,38 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getMessageFromError } from "../get-message-from-error/get-message-from-error";
import { getInjectable } from "@ogre-tools/injectable";
import showErrorNotificationInjectable from "../../notifications/show-error-notification.injectable";
import loggerInjectable from "../../../../common/logger.injectable";
import readFileBufferInjectable from "../../../../common/fs/read-file-buffer.injectable";
export type ReadFileNotify = (filePath: string, showError?: boolean) => Promise<Buffer | null>;
const readFileNotifyInjectable = getInjectable({
id: "read-file-notify",
instantiate: (di): ReadFileNotify => {
const showErrorNotification = di.inject(showErrorNotificationInjectable);
const logger = di.inject(loggerInjectable);
const readFileBuffer = di.inject(readFileBufferInjectable);
return async (filePath, showError = true) => {
try {
return await readFileBuffer(filePath);
} catch (error) {
if (showError) {
const message = getMessageFromError(error);
logger.info(`[EXTENSION-INSTALL]: preloading ${filePath} has failed: ${message}`, { error });
showErrorNotification(`Error while reading "${filePath}": ${message}`);
}
}
return null;
};
},
});
export default readFileNotifyInjectable;
@@ -1,23 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import fse from "fs-extra";
import { getMessageFromError } from "../get-message-from-error/get-message-from-error";
import logger from "../../../../main/logger";
import { Notifications } from "../../notifications";
export const readFileNotify = async (filePath: string, showError = true): Promise<Buffer | null> => {
try {
return await fse.readFile(filePath);
} catch (error) {
if (showError) {
const message = getMessageFromError(error);
logger.info(`[EXTENSION-INSTALL]: preloading ${filePath} has failed: ${message}`, { error });
Notifications.error(`Error while reading "${filePath}": ${message}`);
}
}
return null;
};
@@ -4,16 +4,16 @@
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { asyncComputed } from "@ogre-tools/injectable-react";
import callForHelmChartReadmeInjectable from "./readme/call-for-helm-chart-readme.injectable";
import helmChartDetailsVersionSelectionInjectable from "./versions/helm-chart-details-version-selection.injectable";
import type { HelmChart } from "../../../../common/k8s-api/endpoints/helm-charts.api";
import requestHelmChartReadmeInjectable from "../../../../common/k8s-api/endpoints/helm-charts.api/request-readme.injectable";
const readmeOfSelectedHelmChartInjectable = getInjectable({
id: "readme-of-selected-helm-chart",
instantiate: (di, chart: HelmChart) => {
const selection = di.inject(helmChartDetailsVersionSelectionInjectable, chart);
const callForHelmChartReadme = di.inject(callForHelmChartReadmeInjectable);
const requestHelmChartReadme = di.inject(requestHelmChartReadmeInjectable);
return asyncComputed(async () => {
const chartVersion = selection.value.get();
@@ -22,7 +22,7 @@ const readmeOfSelectedHelmChartInjectable = getInjectable({
return "";
}
return await callForHelmChartReadme(
return await requestHelmChartReadme(
chartVersion.getRepository(),
chartVersion.getName(),
chartVersion.getVersion(),
@@ -1,29 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { getChartDetails } from "../../../../../common/k8s-api/endpoints/helm-charts.api";
export type CallForHelmChartReadme = (
repo: string,
name: string,
version: string,
) => Promise<string>;
const callForHelmChartReadmeInjectable = getInjectable({
id: "call-for-helm-chart-readme",
instantiate:
(): CallForHelmChartReadme =>
async (repository: string, name: string, version: string) => {
// TODO: Dismantle wrong abstraction
const details = await getChartDetails(repository, name, { version });
return details.readme;
},
causesSideEffects: true,
});
export default callForHelmChartReadmeInjectable;
@@ -4,18 +4,18 @@
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { asyncComputed } from "@ogre-tools/injectable-react";
import callForHelmChartVersionsInjectable from "./versions/call-for-helm-chart-versions.injectable";
import type { HelmChart } from "../../../../common/k8s-api/endpoints/helm-charts.api";
import requestHelmChartVersionsInjectable from "../../../../common/k8s-api/endpoints/helm-charts.api/request-versions.injectable";
const versionsOfSelectedHelmChartInjectable = getInjectable({
id: "versions-of-selected-helm-chart",
instantiate: (di, chart: HelmChart) => {
const callForHelmChartVersions = di.inject(callForHelmChartVersionsInjectable);
const requestHelmChartVersions = di.inject(requestHelmChartVersionsInjectable);
return asyncComputed(
async () =>
await callForHelmChartVersions(chart.getRepository(), chart.getName()),
await requestHelmChartVersions(chart.getRepository(), chart.getName()),
[],
);
},
@@ -1,28 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { HelmChart } from "../../../../../common/k8s-api/endpoints/helm-charts.api";
import { getChartDetails } from "../../../../../common/k8s-api/endpoints/helm-charts.api";
export type CallForHelmChartVersions = (
repo: string,
name: string
) => Promise<HelmChart[]>;
const callForHelmChartVersionsInjectable = getInjectable({
id: "call-for-helm-chart-versions",
instantiate:
(): CallForHelmChartVersions => async (repository: string, name: string) => {
// TODO: Dismantle wrong abstraction
const details = await getChartDetails(repository, name);
return details.versions;
},
causesSideEffects: true,
});
export default callForHelmChartVersionsInjectable;
@@ -1,17 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { GetChartDetailsOptions, IHelmChartDetails } from "../../../common/k8s-api/endpoints/helm-charts.api";
import { getChartDetails } from "../../../common/k8s-api/endpoints/helm-charts.api";
export type GetChartDetails = (repo: string, name: string, opts?: GetChartDetailsOptions) => Promise<IHelmChartDetails>;
const getChartDetailsInjectable = getInjectable({
id: "get-chart-details",
instantiate: (): GetChartDetails => getChartDetails,
causesSideEffects: true,
});
export default getChartDetailsInjectable;
@@ -1,13 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { HelmChartStore } from "./helm-chart.store";
const helmChartStoreInjectable = getInjectable({
id: "helm-chart-store",
instantiate: () => new HelmChartStore(),
});
export default helmChartStoreInjectable;
@@ -1,108 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import semver from "semver";
import { observable, makeObservable } from "mobx";
import { autoBind, sortCompareChartVersions } from "../../utils";
import type { HelmChart } from "../../../common/k8s-api/endpoints/helm-charts.api";
import { getChartDetails, listCharts } from "../../../common/k8s-api/endpoints/helm-charts.api";
import { ItemStore } from "../../../common/item.store";
import flatten from "lodash/flatten";
export interface ChartVersion {
repo: string;
version: string;
}
export class HelmChartStore extends ItemStore<HelmChart> {
@observable versions = observable.map<string, ChartVersion[]>();
constructor() {
super();
makeObservable(this);
autoBind(this);
}
async loadAll() {
try {
const res = await this.loadItems(() => listCharts());
this.failedLoading = false;
return res;
} catch (error) {
this.failedLoading = true;
throw error;
}
}
getByName(name: string, repo?: string) {
if (typeof repo !== "string") {
/**
* FIXME:
* This is here because in strict mode `getByName` MUST be 100% compatiable if called in the
* situation where it is only a "ItemStore"
*/
throw new TypeError("repo must be provided");
}
return this.items.find(chart => chart.getName() === name && chart.getRepository() === repo);
}
protected sortVersions = (versions: ChartVersion[]) => {
return versions
.map(chartVersion => ({ ...chartVersion, __version: semver.coerce(chartVersion.version, { loose: true }) }))
.sort(sortCompareChartVersions)
.map(({ __version, ...chartVersion }) => chartVersion);
};
async getVersions(chartName: string, force?: boolean): Promise<ChartVersion[]> {
const versions = this.versions.get(chartName);
if (versions && !force) {
return versions;
}
const loadVersions = async (repo: string) => {
const { versions } = await getChartDetails(repo, chartName);
return versions.map(chart => ({
repo,
version: chart.getVersion(),
}));
};
if (!this.isLoaded) {
await this.loadAll();
}
const repos = this.items
.filter(chart => chart.getName() === chartName)
.map(chart => chart.getRepository());
const newVersions = await Promise.all(repos.map(loadVersions))
.then(flatten)
.then(this.sortVersions);
this.versions.set(chartName, newVersions);
return newVersions;
}
reset() {
super.reset();
this.versions.clear();
}
/**
* @deprecated Not supported
*/
removeItems(): Promise<void> {
throw new Error("removeItems is not supported");
}
}
export const helmChartStore = new HelmChartStore();
@@ -7,7 +7,6 @@ import "./helm-charts.scss";
import React, { Component } from "react";
import { observer } from "mobx-react";
import { helmChartStore } from "./helm-chart.store";
import type { HelmChart } from "../../../common/k8s-api/endpoints/helm-charts.api";
import { HelmChartDetails } from "./helm-chart-details";
import { ItemListLayout } from "../item-object-list/list-layout";
@@ -21,6 +20,7 @@ import navigateToHelmChartsInjectable from "../../../common/front-end-routing/ro
import { HelmChartIcon } from "./icon";
import helmChartsInjectable from "./helm-charts/helm-charts.injectable";
import selectedHelmChartInjectable from "./helm-charts/selected-helm-chart.injectable";
import { noop } from "lodash";
enum columnId {
name = "name",
@@ -35,9 +35,7 @@ interface Dependencies {
chartName: IComputedValue<string>;
repo: IComputedValue<string>;
};
navigateToHelmCharts: NavigateToHelmCharts;
charts: IAsyncComputed<HelmChart[]>;
selectedChart: IComputedValue<HelmChart | undefined>;
}
@@ -69,17 +67,31 @@ class NonInjectedHelmCharts extends Component<Dependencies> {
};
render() {
const { charts } = this.props;
const selectedChart = this.props.selectedChart.get();
return (
<SiblingsInTabLayout>
<div data-testid="page-for-helm-charts" style={{ display: "none" }}/>
<ItemListLayout
<ItemListLayout<HelmChart, false>
isConfigurable
tableId="helm_charts"
className="HelmCharts"
store={helmChartStore}
store={{
get isLoaded() {
return !charts.pending.get();
},
failedLoading: false,
getTotalCount: () => charts.value.get().length,
isSelected: (item) => item === selectedChart,
toggleSelection: noop,
isSelectedAll: () => false,
toggleSelectionAll: () => false,
pickOnlySelected: () => [],
removeSelectedItems: async () => {},
}}
preloadStores={false}
getItems={() => this.props.charts.value.get()}
isSelectable={false}
sortingCallbacks={{
@@ -132,16 +144,12 @@ class NonInjectedHelmCharts extends Component<Dependencies> {
}
}
export const HelmCharts = withInjectables<Dependencies>(
NonInjectedHelmCharts,
{
getProps: (di) => ({
routeParameters: di.inject(helmChartsRouteParametersInjectable),
navigateToHelmCharts: di.inject(navigateToHelmChartsInjectable),
charts: di.inject(helmChartsInjectable),
selectedChart: di.inject(selectedHelmChartInjectable),
}),
},
);
export const HelmCharts = withInjectables<Dependencies>(NonInjectedHelmCharts, {
getProps: (di) => ({
routeParameters: di.inject(helmChartsRouteParametersInjectable),
navigateToHelmCharts: di.inject(navigateToHelmChartsInjectable),
charts: di.inject(helmChartsInjectable),
selectedChart: di.inject(selectedHelmChartInjectable),
}),
});
@@ -1,17 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { HelmChart } from "../../../../common/k8s-api/endpoints/helm-charts.api";
import { listCharts } from "../../../../common/k8s-api/endpoints/helm-charts.api";
export type CallForHelmCharts = () => Promise<HelmChart[]>;
const callForHelmChartsInjectable = getInjectable({
id: "call-for-helm-charts",
instantiate: (): CallForHelmCharts => async () => await listCharts(),
causesSideEffects: true,
});
export default callForHelmChartsInjectable;
@@ -4,15 +4,15 @@
*/
import { getInjectable } from "@ogre-tools/injectable";
import { asyncComputed } from "@ogre-tools/injectable-react";
import callForHelmChartsInjectable from "./call-for-helm-charts.injectable";
import requestHelmChartsInjectable from "../../../../common/k8s-api/endpoints/helm-charts.api/request-charts.injectable";
const helmChartsInjectable = getInjectable({
id: "helm-charts",
instantiate: (di) => {
const callForHelmCharts = di.inject(callForHelmChartsInjectable);
const requestHelmCharts = di.inject(requestHelmChartsInjectable);
return asyncComputed(async () => await callForHelmCharts(), []);
return asyncComputed(async () => await requestHelmCharts(), []);
},
});
@@ -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 type { HelmChart } from "../../../../common/k8s-api/endpoints/helm-charts.api";
import requestHelmChartVersionsInjectable from "../../../../common/k8s-api/endpoints/helm-charts.api/request-versions.injectable";
import type { HelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import { sortBySemverVersion } from "../../../utils";
import type { HelmChartVersion } from "./versions";
/**
* @param release The release to try and figure out what chart versions match for
* @param charts The list of possible helm charts that `release` could be of
*/
export type RequestVersionsOfHelmChart = (release: HelmRelease, charts: HelmChart[]) => Promise<HelmChartVersion[]>;
const requestVersionsOfHelmChartInjectable = getInjectable({
id: "request-versions-of-helm-chart-for-helm-release-from-list-of-charts",
instantiate: (di): RequestVersionsOfHelmChart => {
const requestHelmChartVersions = di.inject(requestHelmChartVersionsInjectable);
return async (release, charts) => {
const rawVersions = await Promise.all((
charts
.filter(chart => chart.getName() === release.getChart())
.map(chart => chart.getRepository())
.map(repo => requestHelmChartVersions(repo, release.getChart()))
));
return sortBySemverVersion(rawVersions.flat());
};
},
});
export default requestVersionsOfHelmChartInjectable;
@@ -0,0 +1,28 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import requestHelmChartsInjectable from "../../../../common/k8s-api/endpoints/helm-charts.api/request-charts.injectable";
import type { HelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import requestVersionsOfHelmChartInjectable from "./request-versions-of-chart-for-release.injectable";
import type { HelmChartVersion } from "./versions";
export type RequestVersionsOfHelmChartFor = (release: HelmRelease) => Promise<HelmChartVersion[]>;
const requestVersionsOfHelmChartForInjectable = getInjectable({
id: "request-versions-of-helm-chart-for",
instantiate: (di): RequestVersionsOfHelmChartFor => {
const requestHelmCharts = di.inject(requestHelmChartsInjectable);
const requestVersionsOfHelmChart = di.inject(requestVersionsOfHelmChartInjectable);
return async (release) => {
const charts = await requestHelmCharts();
return requestVersionsOfHelmChart(release, charts);
};
},
});
export default requestVersionsOfHelmChartForInjectable;
@@ -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, lifecycleEnum } from "@ogre-tools/injectable";
import { asyncComputed } from "@ogre-tools/injectable-react";
import { when } from "mobx";
import type { HelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import helmChartsInjectable from "./helm-charts.injectable";
import requestVersionsOfHelmChartInjectable from "./request-versions-of-chart-for-release.injectable";
const helmChartVersionsInjectable = getInjectable({
id: "helm-chart-versions-loader",
instantiate: (di, release) => {
const helmCharts = di.inject(helmChartsInjectable);
const requestVersionsOfHelmChart = di.inject(requestVersionsOfHelmChartInjectable);
return asyncComputed(async () => {
await when(() => !helmCharts.pending.get());
return requestVersionsOfHelmChart(release, helmCharts.value.get());
}, []);
},
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, release: HelmRelease) => release.getName(),
}),
});
export default helmChartVersionsInjectable;
@@ -0,0 +1,12 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
/**
* A type for the possible versions that a helm release was made from
*/
export interface HelmChartVersion {
repo: string;
version: string;
}
@@ -1,15 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import callForHelmReleasesInjectable from "./call-for-helm-releases.injectable";
import { getGlobalOverride } from "../../../../common/test-utils/get-global-override";
export default getGlobalOverride(
callForHelmReleasesInjectable,
() => () => {
throw new Error(
"Tried to call for helm releases without explicit override.",
);
},
);
@@ -1,24 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { HelmReleaseDto } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import { apiBase } from "../../../../common/k8s-api";
import { endpoint } from "../../../../common/k8s-api/endpoints/helm-releases.api";
export type CallForHelmReleases = (
namespace?: string
) => Promise<HelmReleaseDto[]>;
const callForHelmReleasesInjectable = getInjectable({
id: "call-for-helm-releases",
instantiate: (): CallForHelmReleases => async (namespace) =>
await apiBase.get<HelmReleaseDto[]>(endpoint({ namespace })),
causesSideEffects: true,
});
export default callForHelmReleasesInjectable;
@@ -1,44 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import yaml from "js-yaml";
import { getInjectable } from "@ogre-tools/injectable";
import type { HelmReleaseUpdateDetails } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import { endpoint } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import { apiBase } from "../../../../common/k8s-api";
interface HelmReleaseCreatePayload {
name?: string;
repo: string;
chart: string;
namespace: string;
version: string;
values: string;
}
export type CallForCreateHelmRelease = (
payload: HelmReleaseCreatePayload
) => Promise<HelmReleaseUpdateDetails>;
const callForCreateHelmReleaseInjectable = getInjectable({
id: "call-for-create-helm-release",
instantiate: (): CallForCreateHelmRelease => (payload) => {
const { repo, chart: rawChart, values: rawValues, ...data } = payload;
const chart = `${repo}/${rawChart}`;
const values = yaml.load(rawValues);
return apiBase.post(endpoint(), {
data: {
chart,
values,
...data,
},
});
},
causesSideEffects: true,
});
export default callForCreateHelmReleaseInjectable;
@@ -5,15 +5,15 @@
import { getInjectable } from "@ogre-tools/injectable";
import releasesInjectable from "../releases.injectable";
import type { CallForCreateHelmRelease } from "./call-for-create-helm-release.injectable";
import callForCreateHelmReleaseInjectable from "./call-for-create-helm-release.injectable";
import type { RequestCreateHelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api/request-create.injectable";
import requestCreateHelmReleaseInjectable from "../../../../common/k8s-api/endpoints/helm-releases.api/request-create.injectable";
const createReleaseInjectable = getInjectable({
id: "create-release",
instantiate: (di): CallForCreateHelmRelease => {
instantiate: (di): RequestCreateHelmRelease => {
const releases = di.inject(releasesInjectable);
const callForCreateRelease = di.inject(callForCreateHelmReleaseInjectable);
const callForCreateRelease = di.inject(requestCreateHelmReleaseInjectable);
return async (payload) => {
const release = await callForCreateRelease(payload);
@@ -3,11 +3,8 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type {
HelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import {
deleteRelease,
} from "../../../../common/k8s-api/endpoints/helm-releases.api";
import type { HelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import requestDeleteHelmReleaseInjectable from "../../../../common/k8s-api/endpoints/helm-releases.api/request-delete.injectable";
import releasesInjectable from "../releases.injectable";
const deleteReleaseInjectable = getInjectable({
@@ -15,9 +12,10 @@ const deleteReleaseInjectable = getInjectable({
instantiate: (di) => {
const releases = di.inject(releasesInjectable);
const requestDeleteHelmRelease = di.inject(requestDeleteHelmReleaseInjectable);
return async (release: HelmRelease) => {
await deleteRelease(release.getName(), release.getNs());
await requestDeleteHelmRelease(release.getName(), release.getNs());
releases.invalidate();
};
@@ -13,20 +13,23 @@ import type { DialogProps } from "../../dialog";
import { Dialog } from "../../dialog";
import { Wizard, WizardStep } from "../../wizard";
import type { HelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import { getReleaseHistory, type HelmReleaseRevision } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import { Select } from "../../select";
import { Notifications } from "../../notifications";
import orderBy from "lodash/orderBy";
import { withInjectables } from "@ogre-tools/injectable-react";
import releaseRollbackDialogStateInjectable from "./state.injectable";
import type { RollbackRelease } from "../rollback-release/rollback-release.injectable";
import rollbackReleaseInjectable from "../rollback-release/rollback-release.injectable";
import type { HelmReleaseRevision, RequestHelmReleaseHistory } from "../../../../common/k8s-api/endpoints/helm-releases.api/request-history.injectable";
import requestHelmReleaseHistoryInjectable from "../../../../common/k8s-api/endpoints/helm-releases.api/request-history.injectable";
export interface ReleaseRollbackDialogProps extends DialogProps {
}
interface Dependencies {
rollbackRelease: (releaseName: string, namespace: string, revisionNumber: number) => Promise<void>;
rollbackRelease: RollbackRelease;
state: IObservableValue<HelmRelease | undefined>;
requestHelmReleaseHistory: RequestHelmReleaseHistory;
}
@observer
@@ -48,7 +51,7 @@ class NonInjectedReleaseRollbackDialog extends React.Component<ReleaseRollbackDi
onOpen = async (release: HelmRelease) => {
this.isLoading.set(true);
const releases = await getReleaseHistory(release.getName(), release.getNs());
const releases = await this.props.requestHelmReleaseHistory(release.getName(), release.getNs());
runInAction(() => {
this.revisions.replace(orderBy(releases, "revision", "desc"));
@@ -134,14 +137,11 @@ class NonInjectedReleaseRollbackDialog extends React.Component<ReleaseRollbackDi
}
}
export const ReleaseRollbackDialog = withInjectables<Dependencies, ReleaseRollbackDialogProps>(
NonInjectedReleaseRollbackDialog,
{
getProps: (di, props) => ({
rollbackRelease: di.inject(rollbackReleaseInjectable),
state: di.inject(releaseRollbackDialogStateInjectable),
...props,
}),
},
);
export const ReleaseRollbackDialog = withInjectables<Dependencies, ReleaseRollbackDialogProps>(NonInjectedReleaseRollbackDialog, {
getProps: (di, props) => ({
...props,
rollbackRelease: di.inject(rollbackReleaseInjectable),
state: di.inject(releaseRollbackDialogStateInjectable),
requestHelmReleaseHistory: di.inject(requestHelmReleaseHistoryInjectable),
}),
});
@@ -1,15 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getGlobalOverride } from "../../../../../../common/test-utils/get-global-override";
import callForHelmReleaseConfigurationInjectable from "./call-for-helm-release-configuration.injectable";
export default getGlobalOverride(
callForHelmReleaseConfigurationInjectable,
() => () => {
throw new Error(
"Tried to call for helm release configuration without explicit override.",
);
},
);
@@ -1,29 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { apiBase } from "../../../../../../common/k8s-api";
import { endpoint } from "../../../../../../common/k8s-api/endpoints/helm-releases.api";
export type CallForHelmReleaseConfiguration = (
name: string,
namespace: string,
all: boolean
) => Promise<string>;
const callForHelmReleaseConfigurationInjectable = getInjectable({
id: "call-for-helm-release-configuration",
instantiate:
(): CallForHelmReleaseConfiguration => async (name, namespace, all: boolean) => {
const route = "values";
const path = endpoint({ name, namespace, route }, { all });
return apiBase.get<string>(path);
},
causesSideEffects: true,
});
export default callForHelmReleaseConfigurationInjectable;
@@ -1,15 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getGlobalOverride } from "../../../../../../../common/test-utils/get-global-override";
import callForHelmReleaseDetailsInjectable from "./call-for-helm-release-details.injectable";
export default getGlobalOverride(
callForHelmReleaseDetailsInjectable,
() => () => {
throw new Error(
"Tried to call for helm release details without explicit override.",
);
},
);
@@ -1,45 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { apiBase } from "../../../../../../../common/k8s-api";
import { endpoint } from "../../../../../../../common/k8s-api/endpoints/helm-releases.api";
import type { KubeJsonApiData } from "../../../../../../../common/k8s-api/kube-json-api";
export interface HelmReleaseDetails {
resources: KubeJsonApiData[];
name: string;
namespace: string;
version: string;
config: string; // release values
manifest: string;
info: {
deleted: string;
description: string;
first_deployed: string;
last_deployed: string;
notes: string;
status: string;
};
}
export type CallForHelmReleaseDetails = (
name: string,
namespace: string
) => Promise<HelmReleaseDetails>;
const callForHelmReleaseDetailsInjectable = getInjectable({
id: "call-for-helm-release-details",
instantiate: (): CallForHelmReleaseDetails => async (name, namespace) => {
const path = endpoint({ name, namespace });
return apiBase.get<HelmReleaseDetails>(path);
},
causesSideEffects: true,
});
export default callForHelmReleaseDetailsInjectable;
@@ -1,51 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { HelmReleaseDto } from "../../../../../../common/k8s-api/endpoints/helm-releases.api";
import callForHelmReleasesInjectable from "../../../call-for-helm-releases/call-for-helm-releases.injectable";
import type { HelmReleaseDetails } from "./call-for-helm-release-details/call-for-helm-release-details.injectable";
import callForHelmReleaseDetailsInjectable from "./call-for-helm-release-details/call-for-helm-release-details.injectable";
import type { AsyncResult } from "../../../../../../common/utils/async-result";
export interface DetailedHelmRelease {
release: HelmReleaseDto;
details: HelmReleaseDetails;
}
export type CallForHelmRelease = (
name: string,
namespace: string
) => Promise<AsyncResult<DetailedHelmRelease | undefined>>;
const callForHelmReleaseInjectable = getInjectable({
id: "call-for-helm-release",
instantiate: (di): CallForHelmRelease => {
const callForHelmReleases = di.inject(callForHelmReleasesInjectable);
const callForHelmReleaseDetails = di.inject(callForHelmReleaseDetailsInjectable);
return async (name, namespace) => {
const [releases, details] = await Promise.all([
callForHelmReleases(namespace),
callForHelmReleaseDetails(name, namespace),
]);
const release = releases.find(
(rel) => rel.name === name && rel.namespace === namespace,
);
if (!release) {
return {
callWasSuccessful: false,
error: `Release ${name} didn't exist in ${namespace} namespace.`,
};
}
return { callWasSuccessful: true, response: { release, details }};
};
},
});
export default callForHelmReleaseInjectable;
@@ -6,18 +6,17 @@ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import type { IComputedValue, IObservableValue } from "mobx";
import { runInAction, action, observable, computed } from "mobx";
import type { TargetHelmRelease } from "../target-helm-release.injectable";
import type { CallForHelmRelease, DetailedHelmRelease } from "./call-for-helm-release/call-for-helm-release.injectable";
import callForHelmReleaseInjectable from "./call-for-helm-release/call-for-helm-release.injectable";
import type { RequestDetailedHelmRelease, DetailedHelmRelease } from "./request-detailed-helm-release.injectable";
import requestDetailedHelmReleaseInjectable from "./request-detailed-helm-release.injectable";
import type { LensTheme } from "../../../../themes/store";
import type { CallForHelmReleaseConfiguration } from "./call-for-helm-release-configuration/call-for-helm-release-configuration.injectable";
import callForHelmReleaseConfigurationInjectable from "./call-for-helm-release-configuration/call-for-helm-release-configuration.injectable";
import { toHelmRelease } from "../../releases.injectable";
import type { RequestHelmReleaseConfiguration } from "../../../../../common/k8s-api/endpoints/helm-releases.api/request-configuration.injectable";
import requestHelmReleaseConfigurationInjectable from "../../../../../common/k8s-api/endpoints/helm-releases.api/request-configuration.injectable";
import { pipeline } from "@ogre-tools/fp";
import { groupBy, map } from "lodash/fp";
import type { KubeJsonApiData } from "../../../../../common/k8s-api/kube-json-api";
import type { GetResourceDetailsUrl } from "./get-resource-details-url.injectable";
import getResourceDetailsUrlInjectable from "./get-resource-details-url.injectable";
import type { CallForHelmReleaseUpdate } from "../../update-release/call-for-helm-release-update/call-for-helm-release-update.injectable";
import type { RequestHelmReleaseUpdate } from "../../../../../common/k8s-api/endpoints/helm-releases.api/request-update.injectable";
import updateReleaseInjectable from "../../update-release/update-release.injectable";
import type { ShowCheckedErrorNotification } from "../../../notifications/show-checked-error.injectable";
import showCheckedErrorNotificationInjectable from "../../../notifications/show-checked-error.injectable";
@@ -26,35 +25,29 @@ import showSuccessNotificationInjectable from "../../../notifications/show-succe
import React from "react";
import createUpgradeChartTabInjectable from "../../../dock/upgrade-chart/create-upgrade-chart-tab.injectable";
import type { HelmRelease } from "../../../../../common/k8s-api/endpoints/helm-releases.api";
import type { NavigateToHelmReleases } from "../../../../../common/front-end-routing/routes/cluster/helm/releases/navigate-to-helm-releases.injectable";
import navigateToHelmReleasesInjectable from "../../../../../common/front-end-routing/routes/cluster/helm/releases/navigate-to-helm-releases.injectable";
import assert from "assert";
import activeThemeInjectable from "../../../../themes/active.injectable";
import type { ToHelmRelease } from "../../to-helm-release.injectable";
import toHelmReleaseInjectable from "../../to-helm-release.injectable";
const releaseDetailsModelInjectable = getInjectable({
id: "release-details-model",
instantiate: async (di, targetRelease: TargetHelmRelease) => {
const callForHelmRelease = di.inject(callForHelmReleaseInjectable);
const callForHelmReleaseConfiguration = di.inject(callForHelmReleaseConfigurationInjectable);
const activeTheme = di.inject(activeThemeInjectable);
const getResourceDetailsUrl = di.inject(getResourceDetailsUrlInjectable);
const updateRelease = di.inject(updateReleaseInjectable);
const showCheckedErrorNotification = di.inject(showCheckedErrorNotificationInjectable);
const showSuccessNotification = di.inject(showSuccessNotificationInjectable);
const createUpgradeChartTab = di.inject(createUpgradeChartTabInjectable);
const navigateToHelmReleases = di.inject(navigateToHelmReleasesInjectable);
const model = new ReleaseDetailsModel({
callForHelmRelease,
requestDetailedHelmRelease: di.inject(requestDetailedHelmReleaseInjectable),
targetRelease,
activeTheme,
callForHelmReleaseConfiguration,
getResourceDetailsUrl,
updateRelease,
showCheckedErrorNotification,
showSuccessNotification,
createUpgradeChartTab,
navigateToHelmReleases,
activeTheme: di.inject(activeThemeInjectable),
requestHelmReleaseConfiguration: di.inject(requestHelmReleaseConfigurationInjectable),
getResourceDetailsUrl: di.inject(getResourceDetailsUrlInjectable),
updateRelease: di.inject(updateReleaseInjectable),
showCheckedErrorNotification: di.inject(showCheckedErrorNotificationInjectable),
showSuccessNotification: di.inject(showSuccessNotificationInjectable),
createUpgradeChartTab: di.inject(createUpgradeChartTabInjectable),
navigateToHelmReleases: di.inject(navigateToHelmReleasesInjectable),
toHelmRelease: di.inject(toHelmReleaseInjectable),
});
await model.load();
@@ -85,20 +78,21 @@ export interface ConfigurationInput {
interface Dependencies {
readonly targetRelease: TargetHelmRelease;
readonly activeTheme: IComputedValue<LensTheme>;
callForHelmRelease: CallForHelmRelease;
callForHelmReleaseConfiguration: CallForHelmReleaseConfiguration;
requestDetailedHelmRelease: RequestDetailedHelmRelease;
requestHelmReleaseConfiguration: RequestHelmReleaseConfiguration;
getResourceDetailsUrl: GetResourceDetailsUrl;
updateRelease: CallForHelmReleaseUpdate;
updateRelease: RequestHelmReleaseUpdate;
showCheckedErrorNotification: ShowCheckedErrorNotification;
showSuccessNotification: ShowNotification;
createUpgradeChartTab: (release: HelmRelease) => string;
navigateToHelmReleases: () => void;
navigateToHelmReleases: NavigateToHelmReleases;
toHelmRelease: ToHelmRelease;
}
export class ReleaseDetailsModel {
readonly id = `${this.dependencies.targetRelease.namespace}/${this.dependencies.targetRelease.name}`;
constructor(private readonly dependencies: Dependencies) {}
constructor(protected readonly dependencies: Dependencies) {}
private readonly detailedRelease = observable.box<DetailedHelmRelease | undefined>();
@@ -171,7 +165,7 @@ export class ReleaseDetailsModel {
load = async () => {
const { name, namespace } = this.dependencies.targetRelease;
const result = await this.dependencies.callForHelmRelease(
const result = await this.dependencies.requestDetailedHelmRelease(
name,
namespace,
);
@@ -199,7 +193,7 @@ export class ReleaseDetailsModel {
const { name, namespace } = this.release;
const configuration =
await this.dependencies.callForHelmReleaseConfiguration(
await this.dependencies.requestHelmReleaseConfiguration(
name,
namespace,
!this.onlyUserSuppliedValuesAreShown.value.get(),
@@ -216,7 +210,7 @@ export class ReleaseDetailsModel {
assert(detailedRelease, "Tried to access release before load");
return toHelmRelease(detailedRelease.release);
return this.dependencies.toHelmRelease(detailedRelease.release);
}
@computed private get details() {
@@ -0,0 +1,51 @@
/**
* 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 { HelmReleaseDto } from "../../../../../common/k8s-api/endpoints/helm-releases.api";
import requestHelmReleasesInjectable from "../../../../../common/k8s-api/endpoints/helm-releases.api/request-releases.injectable";
import type { HelmReleaseDetails } from "../../../../../common/k8s-api/endpoints/helm-releases.api/request-details.injectable";
import requestHelmReleaseDetailsInjectable from "../../../../../common/k8s-api/endpoints/helm-releases.api/request-details.injectable";
import type { AsyncResult } from "../../../../../common/utils/async-result";
export interface DetailedHelmRelease {
release: HelmReleaseDto;
details: HelmReleaseDetails;
}
export type RequestDetailedHelmRelease = (
name: string,
namespace: string
) => Promise<AsyncResult<DetailedHelmRelease>>;
const requestDetailedHelmReleaseInjectable = getInjectable({
id: "request-detailed-helm-release",
instantiate: (di): RequestDetailedHelmRelease => {
const requestHelmReleases = di.inject(requestHelmReleasesInjectable);
const requestHelmReleaseDetails = di.inject(requestHelmReleaseDetailsInjectable);
return async (name, namespace) => {
const [releases, details] = await Promise.all([
requestHelmReleases(namespace),
requestHelmReleaseDetails(name, namespace),
]);
const release = releases.find(
(rel) => rel.name === name && rel.namespace === namespace,
);
if (!release) {
return {
callWasSuccessful: false,
error: `Release ${name} didn't exist in ${namespace} namespace.`,
};
}
return { callWasSuccessful: true, response: { release, details }};
};
},
});
export default requestDetailedHelmReleaseInjectable;
@@ -61,7 +61,10 @@ class NonInjectedHelmReleaseMenu extends React.Component<HelmReleaseMenuProps &
<span className="title">Rollback</span>
</MenuItem>
)}
<MenuItem onClick={this.upgrade}>
<MenuItem
onClick={this.upgrade}
data-testid={`upgrade-chart-menu-item-for-${release.getId()}`}
>
<Icon
material="refresh"
interactive={toolbar}
@@ -79,6 +82,10 @@ class NonInjectedHelmReleaseMenu extends React.Component<HelmReleaseMenuProps &
return (
<MenuActions
id={`menu-actions-for-release-menu-for-${release.getId()}`}
triggerIcon={{
material: "more_vert",
"data-testid": `menu-actions-icon-for-release-menu-for-${release.getId()}`,
}}
{...menuProps}
className={cssNames("HelmReleaseMenu", className)}
removeAction={this.remove}
@@ -4,113 +4,32 @@
*/
import { getInjectable } from "@ogre-tools/injectable";
import { asyncComputed } from "@ogre-tools/injectable-react";
import namespaceStoreInjectable from "../+namespaces/store.injectable";
import clusterFrameContextInjectable from "../../cluster-frame-context/cluster-frame-context.injectable";
import releaseSecretsInjectable from "./release-secrets.injectable";
import callForHelmReleasesInjectable from "./call-for-helm-releases/call-for-helm-releases.injectable";
import type { HelmRelease, HelmReleaseDto } from "../../../common/k8s-api/endpoints/helm-releases.api";
import { formatDuration } from "../../../common/utils";
import { helmChartStore } from "../+helm-charts/helm-chart.store";
import { capitalize } from "lodash/fp";
import requestHelmReleasesInjectable from "../../../common/k8s-api/endpoints/helm-releases.api/request-releases.injectable";
import toHelmReleaseInjectable from "./to-helm-release.injectable";
const releasesInjectable = getInjectable({
id: "releases",
instantiate: (di) => {
const clusterContext = di.inject(clusterFrameContextInjectable);
const namespaceStore = di.inject(namespaceStoreInjectable);
const releaseSecrets = di.inject(releaseSecretsInjectable);
const callForHelmReleases = di.inject(callForHelmReleasesInjectable);
const requestHelmReleases = di.inject(requestHelmReleasesInjectable);
const toHelmRelease = di.inject(toHelmReleaseInjectable);
return asyncComputed(async () => {
const contextNamespaces = namespaceStore.contextNamespaces || [];
void releaseSecrets.get();
const isLoadingAll =
clusterContext.allNamespaces?.length > 1 &&
clusterContext.cluster?.accessibleNamespaces.length === 0 &&
clusterContext.allNamespaces.every((namespace) =>
contextNamespaces.includes(namespace),
);
const releaseArrays = await (isLoadingAll ? callForHelmReleases() : Promise.all(
contextNamespaces.map((namespace) =>
callForHelmReleases(namespace),
),
));
const releaseArrays = await (clusterContext.hasSelectedAll
? requestHelmReleases()
: Promise.all(clusterContext.contextNamespaces.map(namespace => requestHelmReleases(namespace)))
);
return releaseArrays.flat().map(toHelmRelease);
}, []);
},
});
export const toHelmRelease = (release: HelmReleaseDto) : HelmRelease => ({
...release,
getId() {
return `${this.namespace}/${this.name}`;
},
getName() {
return this.name;
},
getNs() {
return this.namespace;
},
getChart(withVersion = false) {
let chart = this.chart;
if (!withVersion && this.getVersion() != "") {
const search = new RegExp(`-${this.getVersion()}`);
chart = chart.replace(search, "");
}
return chart;
},
getRevision() {
return parseInt(this.revision, 10);
},
getStatus() {
return capitalize(this.status);
},
getVersion() {
const versions = this.chart.match(/(?<=-)(v?\d+)[^-].*$/);
return versions?.[0] ?? "";
},
getUpdated(humanize = true, compact = true) {
const updated = this.updated.replace(/\s\w*$/, ""); // 2019-11-26 10:58:09 +0300 MSK -> 2019-11-26 10:58:09 +0300 to pass into Date()
const updatedDate = new Date(updated).getTime();
const diff = Date.now() - updatedDate;
if (humanize) {
return formatDuration(diff, compact);
}
return diff;
},
// Helm does not store from what repository the release is installed,
// so we have to try to guess it by searching charts
async getRepo() {
const chartName = this.getChart();
const version = this.getVersion();
const versions = await helmChartStore.getVersions(chartName);
const chartVersion = versions.find(
(chartVersion) => chartVersion.version === version,
);
return chartVersion ? chartVersion.repo : "";
},
});
export default releasesInjectable;
@@ -3,17 +3,20 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { rollbackRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import requestHelmReleaseRollbackInjectable from "../../../../common/k8s-api/endpoints/helm-releases.api/request-rollback.injectable";
import releasesInjectable from "../releases.injectable";
export type RollbackRelease = (name: string, namespace: string, revision: number) => Promise<void>;
const rollbackReleaseInjectable = getInjectable({
id: "rollback-release",
instantiate: (di) => {
instantiate: (di): RollbackRelease => {
const releases = di.inject(releasesInjectable);
const requestHelmReleaseRollback = di.inject(requestHelmReleaseRollbackInjectable);
return async (name: string, namespace: string, revision: number) => {
await rollbackRelease(name, namespace, revision);
return async (name, namespace, revision) => {
await requestHelmReleaseRollback(name, namespace, revision);
releases.invalidate();
};
@@ -0,0 +1,90 @@
/**
* 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 { capitalize } from "lodash";
import { when } from "mobx";
import helmChartVersionsInjectable from "../+helm-charts/helm-charts/versions.injectable";
import type { HelmRelease, HelmReleaseDto } from "../../../common/k8s-api/endpoints/helm-releases.api";
import { getMillisecondsFromUnixEpoch } from "../../../common/utils/date/get-current-date-time";
import { formatDuration } from "../../utils";
export type ToHelmRelease = (release: HelmReleaseDto) => HelmRelease;
const toHelmReleaseInjectable = getInjectable({
id: "to-helm-release",
instantiate: (di): ToHelmRelease => {
const helmChartVersions = (release: HelmRelease) => di.inject(helmChartVersionsInjectable, release);
return (release) => ({
...release,
getId() {
return `${this.namespace}/${this.name}`;
},
getName() {
return this.name;
},
getNs() {
return this.namespace;
},
getChart(withVersion = false) {
let chart = this.chart;
if (!withVersion && this.getVersion() != "") {
const search = new RegExp(`-${this.getVersion()}`);
chart = chart.replace(search, "");
}
return chart;
},
getRevision() {
return parseInt(this.revision, 10);
},
getStatus() {
return capitalize(this.status);
},
getVersion() {
const versions = this.chart.match(/(?<=-)(v?\d+)[^-].*$/);
return versions?.[0] ?? "";
},
getUpdated(humanize = true, compact = true) {
const updated = this.updated.replace(/\s\w*$/, ""); // 2019-11-26 10:58:09 +0300 MSK -> 2019-11-26 10:58:09 +0300 to pass into Date()
const updatedDate = new Date(updated).getTime();
const diff = getMillisecondsFromUnixEpoch() - updatedDate;
if (humanize) {
return formatDuration(diff, compact);
}
return diff;
},
// Helm does not store from what repository the release is installed,
// so we have to try to guess it by searching charts
async getRepo() {
const versionsComputed = helmChartVersions(this);
const version = this.getVersion();
await when(() => !versionsComputed.pending.get());
return versionsComputed.value
.get()
.find((chartVersion) => chartVersion.version === version)?.repo
?? "";
},
});
},
});
export default toHelmReleaseInjectable;
@@ -1,15 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getGlobalOverride } from "../../../../../common/test-utils/get-global-override";
import callForHelmReleaseUpdateInjectable from "./call-for-helm-release-update.injectable";
export default getGlobalOverride(
callForHelmReleaseUpdateInjectable,
() => () => {
throw new Error(
"Tried to call for helm release update without explicit override.",
);
},
);
@@ -1,50 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { apiBase } from "../../../../../common/k8s-api";
import { endpoint } from "../../../../../common/k8s-api/endpoints/helm-releases.api";
import yaml from "js-yaml";
interface HelmReleaseUpdatePayload {
repo: string;
chart: string;
version: string;
values: string;
}
export type CallForHelmReleaseUpdate = (
name: string,
namespace: string,
payload: HelmReleaseUpdatePayload
) => Promise<{ updateWasSuccessful: true } | { updateWasSuccessful: false; error: unknown }>;
const callForHelmReleaseUpdateInjectable = getInjectable({
id: "call-for-helm-release-update",
instantiate:
(): CallForHelmReleaseUpdate => async (name, namespace, payload) => {
const { repo, chart: rawChart, values: rawValues, ...data } = payload;
const chart = `${repo}/${rawChart}`;
const values = yaml.load(rawValues);
try {
await apiBase.put(endpoint({ name, namespace }), {
data: {
chart,
values,
...data,
},
});
} catch (e) {
return { updateWasSuccessful: false, error: e };
}
return { updateWasSuccessful: true };
},
causesSideEffects: true,
});
export default callForHelmReleaseUpdateInjectable;
@@ -4,15 +4,15 @@
*/
import { getInjectable } from "@ogre-tools/injectable";
import releasesInjectable from "../releases.injectable";
import type { CallForHelmReleaseUpdate } from "./call-for-helm-release-update/call-for-helm-release-update.injectable";
import callForHelmReleaseUpdateInjectable from "./call-for-helm-release-update/call-for-helm-release-update.injectable";
import type { RequestHelmReleaseUpdate } from "../../../../common/k8s-api/endpoints/helm-releases.api/request-update.injectable";
import requestHelmReleaseUpdateInjectable from "../../../../common/k8s-api/endpoints/helm-releases.api/request-update.injectable";
const updateReleaseInjectable = getInjectable({
id: "update-release",
instantiate: (di): CallForHelmReleaseUpdate => {
instantiate: (di): RequestHelmReleaseUpdate => {
const releases = di.inject(releasesInjectable);
const callForHelmReleaseUpdate = di.inject(callForHelmReleaseUpdateInjectable);
const callForHelmReleaseUpdate = di.inject(requestHelmReleaseUpdateInjectable);
return async (
name,
@@ -10,7 +10,7 @@ import { computed, makeObservable, observable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { DrawerItem } from "../drawer";
import { cssNames } from "../../utils";
import { getMetricsForNamespace, type PodMetricData, Namespace } from "../../../common/k8s-api/endpoints";
import { Namespace } from "../../../common/k8s-api/endpoints";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import { Link } from "react-router-dom";
import { Spinner } from "../spinner";
@@ -31,6 +31,8 @@ import getActiveClusterEntityInjectable from "../../api/catalog/entity/get-activ
import getDetailsUrlInjectable from "../kube-detail-params/get-details-url.injectable";
import limitRangeStoreInjectable from "../+config-limit-ranges/store.injectable";
import resourceQuotaStoreInjectable from "../+config-resource-quotas/store.injectable";
import type { PodMetricInNamespaceData, RequestPodMetricsInNamespace } from "../../../common/k8s-api/endpoints/metrics.api/request-pod-metrics-in-namespace.injectable";
import requestPodMetricsInNamespaceInjectable from "../../../common/k8s-api/endpoints/metrics.api/request-pod-metrics-in-namespace.injectable";
export interface NamespaceDetailsProps extends KubeObjectDetailsProps<Namespace> {
}
@@ -41,11 +43,12 @@ interface Dependencies {
getDetailsUrl: GetDetailsUrl;
resourceQuotaStore: ResourceQuotaStore;
limitRangeStore: LimitRangeStore;
requestPodMetricsInNamespace: RequestPodMetricsInNamespace;
}
@observer
class NonInjectedNamespaceDetails extends React.Component<NamespaceDetailsProps & Dependencies> {
@observable metrics: PodMetricData | null = null;
@observable metrics: PodMetricInNamespaceData | null = null;
constructor(props: NamespaceDetailsProps & Dependencies) {
super(props);
@@ -78,7 +81,7 @@ class NonInjectedNamespaceDetails extends React.Component<NamespaceDetailsProps
}
loadMetrics = async () => {
this.metrics = await getMetricsForNamespace(this.props.object.getName(), "");
this.metrics = await this.props.requestPodMetricsInNamespace(this.props.object.getName());
};
render() {
@@ -144,6 +147,7 @@ export const NamespaceDetails = withInjectables<Dependencies, NamespaceDetailsPr
getDetailsUrl: di.inject(getDetailsUrlInjectable),
limitRangeStore: di.inject(limitRangeStoreInjectable),
resourceQuotaStore: di.inject(resourceQuotaStoreInjectable),
requestPodMetricsInNamespace: di.inject(requestPodMetricsInNamespaceInjectable),
}),
});
@@ -16,19 +16,26 @@ import { ResourceMetrics } from "../resource-metrics";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import { IngressCharts } from "./ingress-charts";
import { KubeObjectMeta } from "../kube-object-meta";
import { computeRuleDeclarations, getMetricsForIngress, type IngressMetricData } from "../../../common/k8s-api/endpoints/ingress.api";
import { computeRuleDeclarations } from "../../../common/k8s-api/endpoints/ingress.api";
import { getActiveClusterEntity } from "../../api/catalog/entity/legacy-globals";
import { ClusterMetricsResourceType } from "../../../common/cluster-types";
import logger from "../../../common/logger";
import type { IngressMetricData, RequestIngressMetrics } from "../../../common/k8s-api/endpoints/metrics.api/request-ingress-metrics.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import requestIngressMetricsInjectable from "../../../common/k8s-api/endpoints/metrics.api/request-ingress-metrics.injectable";
export interface IngressDetailsProps extends KubeObjectDetailsProps<Ingress> {
}
interface Dependencies {
requestIngressMetrics: RequestIngressMetrics;
}
@observer
export class IngressDetails extends React.Component<IngressDetailsProps> {
class NonInjectedIngressDetails extends React.Component<IngressDetailsProps & Dependencies> {
@observable metrics: IngressMetricData | null = null;
constructor(props: IngressDetailsProps) {
constructor(props: IngressDetailsProps & Dependencies) {
super(props);
makeObservable(this);
}
@@ -42,9 +49,9 @@ export class IngressDetails extends React.Component<IngressDetailsProps> {
}
loadMetrics = async () => {
const { object: ingress } = this.props;
const { object: ingress, requestIngressMetrics } = this.props;
this.metrics = await getMetricsForIngress(ingress.getName(), ingress.getNs());
this.metrics = await requestIngressMetrics(ingress.getName(), ingress.getNs());
};
renderPaths(ingress: Ingress) {
@@ -170,3 +177,10 @@ export class IngressDetails extends React.Component<IngressDetailsProps> {
);
}
}
export const IngressDetails = withInjectables<Dependencies, IngressDetailsProps>(NonInjectedIngressDetails, {
getProps: (di, props) => ({
...props,
requestIngressMetrics: di.inject(requestIngressMetricsInjectable),
}),
});
+8 -5
View File
@@ -13,8 +13,7 @@ import { DrawerItem, DrawerItemLabels } from "../drawer";
import { Badge } from "../badge";
import { ResourceMetrics } from "../resource-metrics";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import type { ClusterMetricData } from "../../../common/k8s-api/endpoints";
import { formatNodeTaint, getMetricsByNodeNames, Node } from "../../../common/k8s-api/endpoints";
import { formatNodeTaint, Node } from "../../../common/k8s-api/endpoints";
import { NodeCharts } from "./node-charts";
import { makeObservable, observable, reaction } from "mobx";
import { PodDetailsList } from "../+workloads-pods/pod-details-list";
@@ -30,6 +29,8 @@ import type { PodStore } from "../+workloads-pods/store";
import podStoreInjectable from "../+workloads-pods/store.injectable";
import type { GetActiveClusterEntity } from "../../api/catalog/entity/get-active-cluster-entity.injectable";
import getActiveClusterEntityInjectable from "../../api/catalog/entity/get-active-cluster-entity.injectable";
import type { ClusterMetricData, RequestClusterMetricsByNodeNames } from "../../../common/k8s-api/endpoints/metrics.api/request-cluster-metrics-by-node-names.injectable";
import requestClusterMetricsByNodeNamesInjectable from "../../../common/k8s-api/endpoints/metrics.api/request-cluster-metrics-by-node-names.injectable";
export interface NodeDetailsProps extends KubeObjectDetailsProps<Node> {
}
@@ -38,11 +39,12 @@ interface Dependencies {
subscribeStores: SubscribeStores;
podStore: PodStore;
getActiveClusterEntity: GetActiveClusterEntity;
requestClusterMetricsByNodeNames: RequestClusterMetricsByNodeNames;
}
@observer
class NonInjectedNodeDetails extends React.Component<NodeDetailsProps & Dependencies> {
@observable metrics: Partial<ClusterMetricData> | null = null;
@observable metrics: ClusterMetricData | null = null;
constructor(props: NodeDetailsProps & Dependencies) {
super(props);
@@ -62,9 +64,9 @@ class NonInjectedNodeDetails extends React.Component<NodeDetailsProps & Dependen
}
loadMetrics = async () => {
const { object: node } = this.props;
const { object: node, requestClusterMetricsByNodeNames } = this.props;
this.metrics = await getMetricsByNodeNames([node.getName()]);
this.metrics = await requestClusterMetricsByNodeNames([node.getName()]);
};
render() {
@@ -196,6 +198,7 @@ export const NodeDetails = withInjectables<Dependencies, NodeDetailsProps>(NonIn
subscribeStores: di.inject(subscribeStoresInjectable),
podStore: di.inject(podStoreInjectable),
getActiveClusterEntity: di.inject(getActiveClusterEntityInjectable),
requestClusterMetricsByNodeNames: di.inject(requestClusterMetricsByNodeNamesInjectable),
}),
});
+36 -14
View File
@@ -8,10 +8,9 @@ import React from "react";
import { observer } from "mobx-react";
import { cssNames, interval } from "../../utils";
import { TabLayout } from "../layout/tab-layout-2";
import { nodeStore } from "./legacy-store";
import { KubeObjectListLayout } from "../kube-object-list-layout";
import type { NodeMetricData, Node } from "../../../common/k8s-api/endpoints/node.api";
import { formatNodeTaint, getMetricsForAllNodes } from "../../../common/k8s-api/endpoints/node.api";
import type { Node } from "../../../common/k8s-api/endpoints/node.api";
import { formatNodeTaint } from "../../../common/k8s-api/endpoints/node.api";
import { LineProgress } from "../line-progress";
import { bytesToUnits } from "../../../common/utils/convertMemory";
import { Tooltip, TooltipPosition } from "../tooltip";
@@ -19,10 +18,15 @@ import kebabCase from "lodash/kebabCase";
import upperFirst from "lodash/upperFirst";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import { Badge } from "../badge/badge";
import { eventStore } from "../+events/legacy-store";
import { makeObservable, observable } from "mobx";
import isEmpty from "lodash/isEmpty";
import { KubeObjectAge } from "../kube-object/age";
import type { NodeMetricData, RequestAllNodeMetrics } from "../../../common/k8s-api/endpoints/metrics.api/request-metrics-for-all-nodes.injectable";
import type { NodeStore } from "./store";
import { withInjectables } from "@ogre-tools/injectable-react";
import nodeStoreInjectable from "./store.injectable";
import requestAllNodeMetricsInjectable from "../../../common/k8s-api/endpoints/metrics.api/request-metrics-for-all-nodes.injectable";
import eventStoreInjectable from "../+events/store.injectable";
import type { EventStore } from "../+events/store";
enum columnId {
name = "name",
@@ -42,16 +46,23 @@ type MetricsTooltipFormatter = (metrics: [number, number]) => string;
interface UsageArgs {
node: Node;
title: string;
metricNames: [string, string];
metricNames: [keyof NodeMetricData, keyof NodeMetricData];
formatters: MetricsTooltipFormatter[];
}
@observer
export class NodesRoute extends React.Component {
@observable.ref metrics: Partial<NodeMetricData> = {};
private metricsWatcher = interval(30, async () => this.metrics = await getMetricsForAllNodes());
interface Dependencies {
requestAllNodeMetrics: RequestAllNodeMetrics;
nodeStore: NodeStore;
eventStore: EventStore;
}
constructor(props: any) {
@observer
class NonInjectedNodesRoute extends React.Component<Dependencies> {
@observable metrics: NodeMetricData | null = null;
private metricsWatcher = interval(30, async () => this.metrics = await this.props.requestAllNodeMetrics());
constructor(props: Dependencies) {
super(props);
makeObservable(this);
}
@@ -64,8 +75,8 @@ export class NodesRoute extends React.Component {
this.metricsWatcher.stop();
}
getLastMetricValues(node: Node, metricNames: string[]): number[] {
if (isEmpty(this.metrics)) {
getLastMetricValues(node: Node, metricNames: (keyof NodeMetricData)[]): number[] {
if (!this.metrics) {
return [];
}
@@ -73,7 +84,7 @@ export class NodesRoute extends React.Component {
return metricNames.map(metricName => {
try {
const metric = this.metrics[metricName];
const metric = this.metrics?.[metricName];
const result = metric?.data.result.find(({ metric: { node, instance, kubernetes_node }}) => (
nodeName === node
|| nodeName === instance
@@ -175,6 +186,8 @@ export class NodesRoute extends React.Component {
}
render() {
const { nodeStore, eventStore } = this.props;
return (
<TabLayout>
<KubeObjectListLayout
@@ -251,3 +264,12 @@ export class NodesRoute extends React.Component {
);
}
}
export const NodesRoute = withInjectables<Dependencies>(NonInjectedNodesRoute, {
getProps: (di, props) => ({
...props,
nodeStore: di.inject(nodeStoreInjectable),
eventStore: di.inject(eventStoreInjectable),
requestAllNodeMetrics: di.inject(requestAllNodeMetricsInjectable),
}),
});
@@ -10,26 +10,39 @@ import { makeObservable, observable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { DrawerItem, DrawerTitle } from "../drawer";
import { Badge } from "../badge";
import { podStore } from "../+workloads-pods/legacy-store";
import { Link } from "react-router-dom";
import { ResourceMetrics } from "../resource-metrics";
import { VolumeClaimDiskChart } from "./volume-claim-disk-chart";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import { getMetricsForPvc, type PersistentVolumeClaimMetricData, PersistentVolumeClaim } from "../../../common/k8s-api/endpoints";
import { getActiveClusterEntity } from "../../api/catalog/entity/legacy-globals";
import { PersistentVolumeClaim } from "../../../common/k8s-api/endpoints";
import { ClusterMetricsResourceType } from "../../../common/cluster-types";
import { KubeObjectMeta } from "../kube-object-meta";
import { getDetailsUrl } from "../kube-detail-params";
import logger from "../../../common/logger";
import type { PersistentVolumeClaimMetricData, RequestPersistentVolumeClaimMetrics } from "../../../common/k8s-api/endpoints/metrics.api/request-persistent-volume-claim-metrics.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import requestPersistentVolumeClaimMetricsInjectable from "../../../common/k8s-api/endpoints/metrics.api/request-persistent-volume-claim-metrics.injectable";
import type { GetActiveClusterEntity } from "../../api/catalog/entity/get-active-cluster-entity.injectable";
import type { GetDetailsUrl } from "../kube-detail-params/get-details-url.injectable";
import type { PodStore } from "../+workloads-pods/store";
import getActiveClusterEntityInjectable from "../../api/catalog/entity/get-active-cluster-entity.injectable";
import getDetailsUrlInjectable from "../kube-detail-params/get-details-url.injectable";
import podStoreInjectable from "../+workloads-pods/store.injectable";
export interface PersistentVolumeClaimDetailsProps extends KubeObjectDetailsProps<PersistentVolumeClaim> {
}
interface Dependencies {
requestPersistentVolumeClaimMetrics: RequestPersistentVolumeClaimMetrics;
getActiveClusterEntity: GetActiveClusterEntity;
getDetailsUrl: GetDetailsUrl;
podStore: PodStore;
}
@observer
export class PersistentVolumeClaimDetails extends React.Component<PersistentVolumeClaimDetailsProps> {
class NonInjectedPersistentVolumeClaimDetails extends React.Component<PersistentVolumeClaimDetailsProps & Dependencies> {
@observable metrics: PersistentVolumeClaimMetricData | null = null;
constructor(props: PersistentVolumeClaimDetailsProps) {
constructor(props: PersistentVolumeClaimDetailsProps & Dependencies) {
super(props);
makeObservable(this);
}
@@ -43,13 +56,13 @@ export class PersistentVolumeClaimDetails extends React.Component<PersistentVolu
}
loadMetrics = async () => {
const { object: volumeClaim } = this.props;
const { object: volumeClaim, requestPersistentVolumeClaimMetrics } = this.props;
this.metrics = await getMetricsForPvc(volumeClaim);
this.metrics = await requestPersistentVolumeClaimMetrics(volumeClaim);
};
render() {
const { object: volumeClaim } = this.props;
const { object: volumeClaim, getActiveClusterEntity, podStore, getDetailsUrl } = this.props;
if (!volumeClaim) {
return null;
@@ -119,3 +132,13 @@ export class PersistentVolumeClaimDetails extends React.Component<PersistentVolu
);
}
}
export const PersistentVolumeClaimDetails = withInjectables<Dependencies, PersistentVolumeClaimDetailsProps>(NonInjectedPersistentVolumeClaimDetails, {
getProps: (di, props) => ({
...props,
requestPersistentVolumeClaimMetrics: di.inject(requestPersistentVolumeClaimMetricsInjectable),
getActiveClusterEntity: di.inject(getActiveClusterEntityInjectable),
getDetailsUrl: di.inject(getDetailsUrlInjectable),
podStore: di.inject(podStoreInjectable),
}),
});
@@ -7,20 +7,33 @@ import React from "react";
import type { KubeObjectMenuProps } from "../../kube-object-menu";
import type { ServiceAccount } from "../../../../common/k8s-api/endpoints";
import { MenuItem } from "../../menu";
import { openServiceAccountKubeConfig } from "../../kubeconfig-dialog";
import { Icon } from "../../icon";
import type { OpenServiceAccountKubeConfigDialog } from "../../kubeconfig-dialog/open-service-account-kube-config-dialog.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import openServiceAccountKubeConfigDialogInjectable from "../../kubeconfig-dialog/open-service-account-kube-config-dialog.injectable";
export function ServiceAccountMenu(props: KubeObjectMenuProps<ServiceAccount>) {
const { object, toolbar } = props;
interface Dependencies {
openServiceAccountKubeConfigDialog: OpenServiceAccountKubeConfigDialog;
}
function NonInjectedServiceAccountMenu(props: KubeObjectMenuProps<ServiceAccount> & Dependencies) {
const { object, toolbar, openServiceAccountKubeConfigDialog } = props;
return (
<MenuItem onClick={() => openServiceAccountKubeConfig(object)}>
<MenuItem onClick={() => openServiceAccountKubeConfigDialog(object)}>
<Icon
material="insert_drive_file"
tooltip="Kubeconfig File"
interactive={toolbar}
interactive={toolbar}
/>
<span className="title">Kubeconfig</span>
</MenuItem>
);
}
export const ServiceAccountMenu = withInjectables<Dependencies, KubeObjectMenuProps<ServiceAccount>>(NonInjectedServiceAccountMenu, {
getProps: (di, props) => ({
...props,
openServiceAccountKubeConfigDialog: di.inject(openServiceAccountKubeConfigDialogInjectable),
}),
});
@@ -15,7 +15,7 @@ import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities"
import type { DaemonSetStore } from "./store";
import type { PodStore } from "../+workloads-pods/store";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import { DaemonSet, getMetricsForDaemonSets, type PodMetricData } from "../../../common/k8s-api/endpoints";
import { DaemonSet } from "../../../common/k8s-api/endpoints";
import { ResourceMetrics, ResourceMetricsText } from "../resource-metrics";
import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts";
import { makeObservable, observable, reaction } from "mobx";
@@ -30,6 +30,8 @@ import daemonSetStoreInjectable from "./store.injectable";
import podStoreInjectable from "../+workloads-pods/store.injectable";
import type { GetActiveClusterEntity } from "../../api/catalog/entity/get-active-cluster-entity.injectable";
import getActiveClusterEntityInjectable from "../../api/catalog/entity/get-active-cluster-entity.injectable";
import type { DaemonSetPodMetricData, RequestPodMetricsForDaemonSets } from "../../../common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-daemon-sets.injectable";
import requestPodMetricsForDaemonSetsInjectable from "../../../common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-daemon-sets.injectable";
export interface DaemonSetDetailsProps extends KubeObjectDetailsProps<DaemonSet> {
}
@@ -39,11 +41,12 @@ interface Dependencies {
daemonSetStore: DaemonSetStore;
podStore: PodStore;
getActiveClusterEntity: GetActiveClusterEntity;
requestPodMetricsForDaemonSets: RequestPodMetricsForDaemonSets;
}
@observer
class NonInjectedDaemonSetDetails extends React.Component<DaemonSetDetailsProps & Dependencies> {
@observable metrics: PodMetricData | null = null;
@observable metrics: DaemonSetPodMetricData | null = null;
constructor(props: DaemonSetDetailsProps & Dependencies) {
super(props);
@@ -62,9 +65,9 @@ class NonInjectedDaemonSetDetails extends React.Component<DaemonSetDetailsProps
}
loadMetrics = async () => {
const { object: daemonSet } = this.props;
const { object: daemonSet, requestPodMetricsForDaemonSets } = this.props;
this.metrics = await getMetricsForDaemonSets([daemonSet], daemonSet.getNs(), "");
this.metrics = await requestPodMetricsForDaemonSets([daemonSet], daemonSet.getNs());
};
render() {
@@ -143,5 +146,6 @@ export const DaemonSetDetails = withInjectables<Dependencies, DaemonSetDetailsPr
daemonSetStore: di.inject(daemonSetStoreInjectable),
podStore: di.inject(podStoreInjectable),
getActiveClusterEntity: di.inject(getActiveClusterEntityInjectable),
requestPodMetricsForDaemonSets: di.inject(requestPodMetricsForDaemonSetsInjectable),
}),
});
@@ -10,8 +10,7 @@ import kebabCase from "lodash/kebabCase";
import { disposeOnUnmount, observer } from "mobx-react";
import { DrawerItem } from "../drawer";
import { Badge } from "../badge";
import type { PodMetricData } from "../../../common/k8s-api/endpoints";
import { Deployment, getMetricsForDeployments } from "../../../common/k8s-api/endpoints";
import { Deployment } from "../../../common/k8s-api/endpoints";
import { PodDetailsTolerations } from "../+workloads-pods/pod-details-tolerations";
import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities";
import type { KubeObjectDetailsProps } from "../kube-object-details";
@@ -34,6 +33,8 @@ import replicaSetStoreInjectable from "../+workloads-replicasets/store.injectabl
import deploymentStoreInjectable from "./store.injectable";
import type { GetActiveClusterEntity } from "../../api/catalog/entity/get-active-cluster-entity.injectable";
import getActiveClusterEntityInjectable from "../../api/catalog/entity/get-active-cluster-entity.injectable";
import type { DeploymentPodMetricData, RequestPodMetricsForDeployments } from "../../../common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-deployments.injectable";
import requestPodMetricsForDeploymentsInjectable from "../../../common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-deployments.injectable";
export interface DeploymentDetailsProps extends KubeObjectDetailsProps<Deployment> {
}
@@ -44,11 +45,12 @@ interface Dependencies {
replicaSetStore: ReplicaSetStore;
deploymentStore: DeploymentStore;
getActiveClusterEntity: GetActiveClusterEntity;
requestPodMetricsForDeployments: RequestPodMetricsForDeployments;
}
@observer
class NonInjectedDeploymentDetails extends React.Component<DeploymentDetailsProps & Dependencies> {
@observable metrics: PodMetricData | null = null;
@observable metrics: DeploymentPodMetricData | null = null;
constructor(props: DeploymentDetailsProps & Dependencies) {
super(props);
@@ -69,9 +71,9 @@ class NonInjectedDeploymentDetails extends React.Component<DeploymentDetailsProp
}
loadMetrics = async () => {
const { object: deployment } = this.props;
const { object: deployment, requestPodMetricsForDeployments } = this.props;
this.metrics = await getMetricsForDeployments([deployment], deployment.getNs(), "");
this.metrics = await requestPodMetricsForDeployments([deployment], deployment.getNs());
};
render() {
@@ -173,6 +175,7 @@ export const DeploymentDetails = withInjectables<Dependencies, DeploymentDetails
replicaSetStore: di.inject(replicaSetStoreInjectable),
deploymentStore: di.inject(deploymentStoreInjectable),
getActiveClusterEntity: di.inject(getActiveClusterEntityInjectable),
requestPodMetricsForDeployments: di.inject(requestPodMetricsForDeploymentsInjectable),
}),
});
@@ -15,7 +15,7 @@ import { PodDetailsTolerations } from "../+workloads-pods/pod-details-toleration
import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities";
import type { JobStore } from "./store";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import { getMetricsForJobs, type PodMetricData, Job } from "../../../common/k8s-api/endpoints";
import { Job } from "../../../common/k8s-api/endpoints";
import { PodDetailsList } from "../+workloads-pods/pod-details-list";
import { KubeObjectMeta } from "../kube-object-meta";
import { makeObservable, observable, reaction } from "mobx";
@@ -31,6 +31,8 @@ import podStoreInjectable from "../+workloads-pods/store.injectable";
import jobStoreInjectable from "./store.injectable";
import type { GetActiveClusterEntity } from "../../api/catalog/entity/get-active-cluster-entity.injectable";
import getActiveClusterEntityInjectable from "../../api/catalog/entity/get-active-cluster-entity.injectable";
import type { JobPodMetricData, RequestPodMetricsForJobs } from "../../../common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-jobs.injectable";
import requestPodMetricsForJobsInjectable from "../../../common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-jobs.injectable";
export interface JobDetailsProps extends KubeObjectDetailsProps<Job> {
}
@@ -40,11 +42,12 @@ interface Dependencies {
podStore: PodStore;
jobStore: JobStore;
getActiveClusterEntity: GetActiveClusterEntity;
requestPodMetricsForJobs: RequestPodMetricsForJobs;
}
@observer
class NonInjectedJobDetails extends React.Component<JobDetailsProps & Dependencies> {
@observable metrics: PodMetricData | null = null;
@observable metrics: JobPodMetricData | null = null;
constructor(props: JobDetailsProps & Dependencies) {
super(props);
@@ -63,9 +66,9 @@ class NonInjectedJobDetails extends React.Component<JobDetailsProps & Dependenci
}
loadMetrics = async () => {
const { object: job } = this.props;
const { object: job, requestPodMetricsForJobs } = this.props;
this.metrics = await getMetricsForJobs([job], job.getNs(), "");
this.metrics = await requestPodMetricsForJobs([job], job.getNs(), "");
};
render() {
@@ -159,6 +162,7 @@ export const JobDetails = withInjectables<Dependencies, JobDetailsProps>(NonInje
podStore: di.inject(podStoreInjectable),
jobStore: di.inject(jobStoreInjectable),
getActiveClusterEntity: di.inject(getActiveClusterEntityInjectable),
requestPodMetricsForJobs: di.inject(requestPodMetricsForJobsInjectable),
}),
});
@@ -105,7 +105,7 @@ class NonInjectedPodDetailsContainer extends React.Component<PodDetailsContainer
<StatusBrick className={cssNames(state, { ready })}/>
{name}
</div>
{!isMetricHidden && !isInitContainer && (
{(!isMetricHidden && !isInitContainer && metrics) && (
<ResourceMetrics
object={pod}
tabs={[
@@ -10,8 +10,8 @@ import kebabCase from "lodash/kebabCase";
import { disposeOnUnmount, observer } from "mobx-react";
import { Link } from "react-router-dom";
import { observable, reaction, makeObservable } from "mobx";
import type { PodMetricData } from "../../../common/k8s-api/endpoints";
import { nodeApi, Pod, getMetricsForPods } from "../../../common/k8s-api/endpoints";
import type { NodeApi } from "../../../common/k8s-api/endpoints";
import { Pod } from "../../../common/k8s-api/endpoints";
import { DrawerItem, DrawerTitle } from "../drawer";
import { Badge } from "../badge";
import { cssNames, toJS } from "../../utils";
@@ -24,21 +24,34 @@ import type { KubeObjectDetailsProps } from "../kube-object-details";
import { getItemMetrics } from "../../../common/k8s-api/endpoints/metrics.api";
import { PodCharts, podMetricTabs } from "./pod-charts";
import { KubeObjectMeta } from "../kube-object-meta";
import { getActiveClusterEntity } from "../../api/catalog/entity/legacy-globals";
import { ClusterMetricsResourceType } from "../../../common/cluster-types";
import { getDetailsUrl } from "../kube-detail-params";
import logger from "../../../common/logger";
import { PodVolumes } from "./details/volumes/view";
import type { PodMetricData, RequestPodMetrics } from "../../../common/k8s-api/endpoints/metrics.api/request-pod-metrics.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import requestPodMetricsInjectable from "../../../common/k8s-api/endpoints/metrics.api/request-pod-metrics.injectable";
import type { GetActiveClusterEntity } from "../../api/catalog/entity/get-active-cluster-entity.injectable";
import type { GetDetailsUrl } from "../kube-detail-params/get-details-url.injectable";
import getActiveClusterEntityInjectable from "../../api/catalog/entity/get-active-cluster-entity.injectable";
import getDetailsUrlInjectable from "../kube-detail-params/get-details-url.injectable";
import nodeApiInjectable from "../../../common/k8s-api/endpoints/node.api.injectable";
export interface PodDetailsProps extends KubeObjectDetailsProps<Pod> {
}
interface Dependencies {
requestPodMetrics: RequestPodMetrics;
getActiveClusterEntity: GetActiveClusterEntity;
getDetailsUrl: GetDetailsUrl;
nodeApi: NodeApi;
}
@observer
export class PodDetails extends React.Component<PodDetailsProps> {
class NonInjectedPodDetails extends React.Component<PodDetailsProps & Dependencies> {
@observable metrics: PodMetricData | null = null;
@observable containerMetrics: PodMetricData | null = null;
constructor(props: PodDetailsProps) {
constructor(props: PodDetailsProps & Dependencies) {
super(props);
makeObservable(this);
}
@@ -53,14 +66,14 @@ export class PodDetails extends React.Component<PodDetailsProps> {
}
loadMetrics = async () => {
const { object: pod } = this.props;
const { object: pod, requestPodMetrics } = this.props;
this.metrics = await getMetricsForPods([pod], pod.getNs());
this.containerMetrics = await getMetricsForPods([pod], pod.getNs(), "container, namespace");
this.metrics = await requestPodMetrics([pod], pod.getNs());
this.containerMetrics = await requestPodMetrics([pod], pod.getNs(), "container, namespace");
};
render() {
const { object: pod } = this.props;
const { object: pod, getActiveClusterEntity, getDetailsUrl, nodeApi } = this.props;
if (!pod) {
return null;
@@ -183,3 +196,13 @@ export class PodDetails extends React.Component<PodDetailsProps> {
);
}
}
export const PodDetails = withInjectables<Dependencies, PodDetailsProps>(NonInjectedPodDetails, {
getProps: (di, props) => ({
...props,
requestPodMetrics: di.inject(requestPodMetricsInjectable),
getActiveClusterEntity: di.inject(getActiveClusterEntityInjectable),
getDetailsUrl: di.inject(getDetailsUrlInjectable),
nodeApi: di.inject(nodeApiInjectable),
}),
});
@@ -13,8 +13,7 @@ import { PodDetailsTolerations } from "../+workloads-pods/pod-details-toleration
import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities";
import { disposeOnUnmount, observer } from "mobx-react";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import type { PodMetricData } from "../../../common/k8s-api/endpoints";
import { getMetricsForReplicaSets, ReplicaSet } from "../../../common/k8s-api/endpoints";
import { ReplicaSet } from "../../../common/k8s-api/endpoints";
import { ResourceMetrics, ResourceMetricsText } from "../resource-metrics";
import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts";
import { PodDetailsList } from "../+workloads-pods/pod-details-list";
@@ -30,6 +29,8 @@ import type { ReplicaSetStore } from "./store";
import replicaSetStoreInjectable from "./store.injectable";
import type { GetActiveClusterEntity } from "../../api/catalog/entity/get-active-cluster-entity.injectable";
import getActiveClusterEntityInjectable from "../../api/catalog/entity/get-active-cluster-entity.injectable";
import type { ReplicaSetPodMetricData, RequestPodMetricsForReplicaSets } from "../../../common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-replica-sets.injectable";
import requestPodMetricsForReplicaSetsInjectable from "../../../common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-replica-sets.injectable";
export interface ReplicaSetDetailsProps extends KubeObjectDetailsProps<ReplicaSet> {
}
@@ -39,11 +40,12 @@ interface Dependencies {
podStore: PodStore;
replicaSetStore: ReplicaSetStore;
getActiveClusterEntity: GetActiveClusterEntity;
requestPodMetricsForReplicaSets: RequestPodMetricsForReplicaSets;
}
@observer
class NonInjectedReplicaSetDetails extends React.Component<ReplicaSetDetailsProps & Dependencies> {
@observable metrics: PodMetricData | null = null;
@observable metrics: ReplicaSetPodMetricData | null = null;
constructor(props: ReplicaSetDetailsProps & Dependencies) {
super(props);
@@ -63,9 +65,9 @@ class NonInjectedReplicaSetDetails extends React.Component<ReplicaSetDetailsProp
}
loadMetrics = async () => {
const { object: replicaSet } = this.props;
const { object: replicaSet, requestPodMetricsForReplicaSets } = this.props;
this.metrics = await getMetricsForReplicaSets([replicaSet], replicaSet.getNs(), "");
this.metrics = await requestPodMetricsForReplicaSets([replicaSet], replicaSet.getNs());
};
render() {
@@ -144,5 +146,6 @@ export const ReplicaSetDetails = withInjectables<Dependencies, ReplicaSetDetails
podStore: di.inject(podStoreInjectable),
replicaSetStore: di.inject(replicaSetStoreInjectable),
getActiveClusterEntity: di.inject(getActiveClusterEntityInjectable),
requestPodMetricsForReplicaSets: di.inject(requestPodMetricsForReplicaSetsInjectable),
}),
});
@@ -15,8 +15,7 @@ import { PodDetailsTolerations } from "../+workloads-pods/pod-details-toleration
import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities";
import type { StatefulSetStore } from "./store";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import type { PodMetricData } from "../../../common/k8s-api/endpoints";
import { getMetricsForStatefulSets, StatefulSet } from "../../../common/k8s-api/endpoints";
import { StatefulSet } from "../../../common/k8s-api/endpoints";
import { ResourceMetrics, ResourceMetricsText } from "../resource-metrics";
import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts";
import { PodDetailsList } from "../+workloads-pods/pod-details-list";
@@ -31,6 +30,8 @@ import podStoreInjectable from "../+workloads-pods/store.injectable";
import statefulSetStoreInjectable from "./store.injectable";
import type { GetActiveClusterEntity } from "../../api/catalog/entity/get-active-cluster-entity.injectable";
import getActiveClusterEntityInjectable from "../../api/catalog/entity/get-active-cluster-entity.injectable";
import type { RequestPodMetricsForStatefulSets, StatefulSetPodMetricData } from "../../../common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-stateful-sets.injectable";
import requestPodMetricsForStatefulSetsInjectable from "../../../common/k8s-api/endpoints/metrics.api/request-pod-metrics-for-stateful-sets.injectable";
export interface StatefulSetDetailsProps extends KubeObjectDetailsProps<StatefulSet> {
}
@@ -40,11 +41,12 @@ interface Dependencies {
podStore: PodStore;
statefulSetStore: StatefulSetStore;
getActiveClusterEntity: GetActiveClusterEntity;
requestPodMetricsForStatefulSets: RequestPodMetricsForStatefulSets;
}
@observer
class NonInjectedStatefulSetDetails extends React.Component<StatefulSetDetailsProps & Dependencies> {
@observable metrics: PodMetricData | null = null;
@observable metrics: StatefulSetPodMetricData | null = null;
constructor(props: StatefulSetDetailsProps & Dependencies) {
super(props);
@@ -64,9 +66,9 @@ class NonInjectedStatefulSetDetails extends React.Component<StatefulSetDetailsPr
}
loadMetrics = async () => {
const { object: statefulSet } = this.props;
const { object: statefulSet, requestPodMetricsForStatefulSets } = this.props;
this.metrics = await getMetricsForStatefulSets([statefulSet], statefulSet.getNs(), "");
this.metrics = await requestPodMetricsForStatefulSets([statefulSet], statefulSet.getNs());
};
render() {
@@ -143,6 +145,7 @@ export const StatefulSetDetails = withInjectables<Dependencies, StatefulSetDetai
podStore: di.inject(podStoreInjectable),
statefulSetStore: di.inject(statefulSetStoreInjectable),
getActiveClusterEntity: di.inject(getActiveClusterEntityInjectable),
requestPodMetricsForStatefulSets: di.inject(requestPodMetricsForStatefulSetsInjectable),
}),
});
+73 -98
View File
@@ -4,12 +4,13 @@
*/
import "./animate.scss";
import React from "react";
import { observable, makeObservable } from "mobx";
import { observer } from "mobx-react";
import React, { useEffect, useState } from "react";
import { cssNames, noop } from "../../utils";
import { withInjectables } from "@ogre-tools/injectable-react";
import type { RequestAnimationFrame } from "./request-animation-frame.injectable";
import requestAnimationFrameInjectable from "./request-animation-frame.injectable";
import defaultEnterDurationForAnimatedInjectable from "./default-enter-duration.injectable";
import defaultLeaveDurationForAnimatedInjectable from "./default-leave-duration.injectable";
export type AnimateName = "opacity" | "slide-right" | "opacity-scale" | string;
@@ -24,115 +25,89 @@ export interface AnimateProps {
}
interface Dependencies {
requestAnimationFrame: (callback: () => void) => void;
requestAnimationFrame: RequestAnimationFrame;
defaultEnterDuration: number;
defaultLeaveDuration: number;
}
@observer
class DefaultedAnimate extends React.Component<AnimateProps & Dependencies & typeof DefaultedAnimate.defaultProps> {
static defaultProps = {
name: "opacity",
enter: true,
onEnter: noop,
onLeave: noop,
enterDuration: 100,
leaveDuration: 100,
};
const NonInjectedAnimate = (propsAndDeps: AnimateProps & Dependencies) => {
const {
requestAnimationFrame,
defaultEnterDuration,
defaultLeaveDuration,
...props
} = propsAndDeps;
const {
children,
enter = true,
enterDuration = defaultEnterDuration,
leaveDuration = defaultLeaveDuration,
name = "opacity",
onEnter: onEnterHandler = noop<[]>,
onLeave: onLeaveHandler = noop<[]>,
} = props;
@observable isVisible = !!this.props.enter;
@observable statusClassName = {
enter: false,
leave: false,
};
const [isVisible, setIsVisible] = useState(false);
const [showClassNameEnter, setShowClassNameEnter] = useState(false);
const [showClassNameLeave, setShowClassNameLeave] = useState(false);
constructor(props: AnimateProps & Dependencies & typeof DefaultedAnimate.defaultProps) {
super(props);
makeObservable(this);
}
const contentElem = React.Children.only(children) as React.ReactElement<React.HTMLAttributes<any>>;
const classNames = cssNames("Animate", name, contentElem.props.className, {
enter: showClassNameEnter,
leave: showClassNameLeave,
});
get contentElem() {
return React.Children.only(this.props.children) as React.ReactElement<React.HTMLAttributes<any>>;
}
private toggle(enter: boolean) {
useEffect(() => {
if (enter) {
this.enter();
} else {
this.leave();
}
}
setIsVisible(true);
componentDidMount() {
this.toggle(this.props.enter);
}
requestAnimationFrame(() => {
setShowClassNameEnter(true);
onEnterHandler();
});
componentDidUpdate(prevProps: Readonly<AnimateProps>): void {
const { enter } = this.props;
return noop;
} else if (isVisible) {
setShowClassNameLeave(true);
onLeaveHandler();
if (prevProps.enter !== enter) {
this.toggle(enter);
}
}
// Cleanup after duration
const handle = setTimeout(() => {
setIsVisible(false);
setShowClassNameEnter(false);
setShowClassNameLeave(false);
}, leaveDuration);
enter() {
this.isVisible = true; // triggers render() to apply css-animation in existing dom
this.props.requestAnimationFrame(() => {
this.statusClassName.enter = true;
this.props.onEnter();
});
}
leave() {
if (!this.isVisible) return;
this.statusClassName.leave = true;
this.props.onLeave();
this.resetAfterLeaveDuration();
}
resetAfterLeaveDuration() {
setTimeout(() => this.reset(), this.props.leaveDuration);
}
reset() {
this.isVisible = false;
this.statusClassName.enter = false;
this.statusClassName.leave = false;
}
render() {
if (!this.isVisible) {
return null;
return () => clearTimeout(handle);
}
const { name, enterDuration, leaveDuration } = this.props;
const contentElem = this.contentElem;
const cssVarsForAnimation = {
"--enter-duration": `${enterDuration}ms`,
"--leave-duration": `${leaveDuration}ms`,
} as React.CSSProperties;
return noop;
}, [enter]);
return React.cloneElement(contentElem, {
className: cssNames("Animate", name, contentElem.props.className, this.statusClassName),
children: contentElem.props.children,
style: {
...contentElem.props.style,
...cssVarsForAnimation,
},
});
if (!isVisible) {
return null;
}
}
export const NonInjectedAnimate = (props: AnimateProps & Dependencies) => <DefaultedAnimate {...props} />;
export const Animate = withInjectables<Dependencies, AnimateProps>(
NonInjectedAnimate,
{
getProps: (di, props) => ({
requestAnimationFrame: di.inject(requestAnimationFrameInjectable),
...props,
}),
},
);
const cssVarsForAnimation = {
"--enter-duration": `${enterDuration}ms`,
"--leave-duration": `${leaveDuration}ms`,
} as React.CSSProperties;
return React.cloneElement(contentElem, {
className: classNames,
children: contentElem.props.children,
style: {
...contentElem.props.style,
...cssVarsForAnimation,
},
});
};
export const Animate = withInjectables<Dependencies, AnimateProps>(NonInjectedAnimate, {
getProps: (di, props) => ({
...props,
requestAnimationFrame: di.inject(requestAnimationFrameInjectable),
defaultEnterDuration: di.inject(defaultEnterDurationForAnimatedInjectable),
defaultLeaveDuration: di.inject(defaultLeaveDurationForAnimatedInjectable),
}),
});
@@ -0,0 +1,12 @@
/**
* 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";
const defaultEnterDurationForAnimatedInjectable = getInjectable({
id: "default-enter-duration-for-animated",
instantiate: () => 100,
});
export default defaultEnterDurationForAnimatedInjectable;
@@ -0,0 +1,12 @@
/**
* 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";
const defaultLeaveDurationForAnimatedInjectable = getInjectable({
id: "default-leave-duration-for-animated",
instantiate: () => 100,
});
export default defaultLeaveDurationForAnimatedInjectable;
@@ -4,9 +4,12 @@
*/
import { getInjectable } from "@ogre-tools/injectable";
export type RequestAnimationFrame = (callback: () => void) => void;
const requestAnimationFrameInjectable = getInjectable({
id: "request-animation-frame",
instantiate: () => (callback: () => void) => requestAnimationFrame(callback),
// NOTE: this cannot be simplified to just `=> requestAnimationFrame` else an Illegal Invocation error will be thrown
instantiate: (): RequestAnimationFrame => (callback) => requestAnimationFrame(callback),
causesSideEffects: true,
});
@@ -9,7 +9,6 @@ import type { Cluster } from "../../../../common/cluster/cluster";
import { Input } from "../../input";
import { SubTitle } from "../../layout/sub-title";
import type { ShowNotification } from "../../notifications";
import { resolveTilde } from "../../../utils";
import { Icon } from "../../icon";
import { PathPicker } from "../../path-picker";
import { isWindows } from "../../../../common/vars";
@@ -17,6 +16,8 @@ import { withInjectables } from "@ogre-tools/injectable-react";
import showErrorNotificationInjectable from "../../notifications/show-error-notification.injectable";
import type { ValidateDirectory } from "../../../../common/fs/validate-directory.injectable";
import validateDirectoryInjectable from "../../../../common/fs/validate-directory.injectable";
import type { ResolveTilde } from "../../../../common/path/resolve-tilde.injectable";
import resolveTildeInjectable from "../../../../common/path/resolve-tilde.injectable";
export interface ClusterLocalTerminalSettingProps {
cluster: Cluster;
@@ -24,9 +25,15 @@ export interface ClusterLocalTerminalSettingProps {
interface Dependencies {
showErrorNotification: ShowNotification;
validateDirectory: ValidateDirectory;
resolveTilde: ResolveTilde;
}
const NonInjectedClusterLocalTerminalSetting = observer(({ cluster, showErrorNotification, validateDirectory }: Dependencies & ClusterLocalTerminalSettingProps) => {
const NonInjectedClusterLocalTerminalSetting = observer(({
cluster,
showErrorNotification,
validateDirectory,
resolveTilde,
}: Dependencies & ClusterLocalTerminalSettingProps) => {
if (!cluster) {
return null;
}
@@ -150,15 +157,11 @@ const NonInjectedClusterLocalTerminalSetting = observer(({ cluster, showErrorNot
);
});
export const ClusterLocalTerminalSetting = withInjectables<Dependencies, ClusterLocalTerminalSettingProps>(
NonInjectedClusterLocalTerminalSetting,
{
getProps: (di, props) => ({
showErrorNotification: di.inject(showErrorNotificationInjectable),
validateDirectory: di.inject(validateDirectoryInjectable),
...props,
}),
},
);
export const ClusterLocalTerminalSetting = withInjectables<Dependencies, ClusterLocalTerminalSettingProps>(NonInjectedClusterLocalTerminalSetting, {
getProps: (di, props) => ({
...props,
showErrorNotification: di.inject(showErrorNotificationInjectable),
validateDirectory: di.inject(validateDirectoryInjectable),
resolveTilde: di.inject(resolveTildeInjectable),
}),
});
@@ -11,10 +11,10 @@ import type { SelectOption } from "../../select";
import { Select } from "../../select";
import { Input } from "../../input";
import { observable, computed, autorun, makeObservable } from "mobx";
import type { MetricProviderInfo } from "../../../../common/k8s-api/endpoints/metrics.api";
import { metricsApi } from "../../../../common/k8s-api/endpoints/metrics.api";
import { Spinner } from "../../spinner";
import type { MetricProviderInfo, RequestMetricsProviders } from "../../../../common/k8s-api/endpoints/metrics.api/request-providers.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import requestMetricsProvidersInjectable from "../../../../common/k8s-api/endpoints/metrics.api/request-providers.injectable";
import productNameInjectable from "../../../../common/vars/product-name.injectable";
export interface ClusterPrometheusSettingProps {
@@ -27,6 +27,7 @@ type ProviderValue = typeof autoDetectPrometheus | string;
interface Dependencies {
productName: string;
requestMetricsProviders: RequestMetricsProviders;
}
@observer
@@ -85,8 +86,7 @@ class NonInjectedClusterPrometheusSetting extends React.Component<ClusterPrometh
}),
);
metricsApi
.getMetricProviders()
this.props.requestMetricsProviders()
.then(values => {
this.loading = false;
this.loadedOptions.replace(values.map(provider => [provider.id, provider]));
@@ -174,5 +174,6 @@ export const ClusterPrometheusSetting = withInjectables<Dependencies, ClusterPro
getProps: (di, props) => ({
...props,
productName: di.inject(productNameInjectable),
requestMetricsProviders: di.inject(requestMetricsProvidersInjectable),
}),
});
@@ -1,275 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "@testing-library/jest-dom/extend-expect";
import { KubeConfig } from "@kubernetes/client-node";
import { fireEvent } from "@testing-library/react";
import type { RenderResult } from "@testing-library/react";
import * as selectEvent from "react-select-event";
import type { CreateCluster } from "../../../../common/cluster/create-cluster-injection-token";
import { createClusterInjectionToken } from "../../../../common/cluster/create-cluster-injection-token";
import createContextHandlerInjectable from "../../../../main/context-handler/create-context-handler.injectable";
import storesAndApisCanBeCreatedInjectable from "../../../stores-apis-can-be-created.injectable";
import createKubeconfigManagerInjectable from "../../../../main/kubeconfig-manager/create-kubeconfig-manager.injectable";
import type { ApplicationBuilder } from "../../test-utils/get-application-builder";
import { getApplicationBuilder } from "../../test-utils/get-application-builder";
import normalizedPlatformInjectable from "../../../../common/vars/normalized-platform.injectable";
import kubectlBinaryNameInjectable from "../../../../main/kubectl/binary-name.injectable";
import kubectlDownloadingNormalizedArchInjectable from "../../../../main/kubectl/normalized-arch.injectable";
import type { OpenDeleteClusterDialog } from "../open.injectable";
import openDeleteClusterDialogInjectable from "../open.injectable";
const currentClusterServerUrl = "https://localhost";
const nonCurrentClusterServerUrl = "http://localhost";
const multiClusterConfig = `
apiVersion: v1
clusters:
- cluster:
server: ${currentClusterServerUrl}
name: some-current-context-cluster
- cluster:
server: ${nonCurrentClusterServerUrl}
name: some-non-current-context-cluster
contexts:
- context:
cluster: some-current-context-cluster
user: some-user
name: some-current-context
- context:
cluster: some-non-current-context-cluster
user: some-user
name: some-non-current-context
current-context: some-current-context
kind: Config
preferences: {}
users:
- name: some-user
user:
token: kubeconfig-user-q4lm4:xxxyyyy
`;
const singleClusterServerUrl = "http://localhost";
const singleClusterConfig = `
apiVersion: v1
clusters:
- cluster:
server: ${singleClusterServerUrl}
name: some-cluster
contexts:
- context:
cluster: some-cluster
user: some-user
name: some-context
current-context: some-context
kind: Config
preferences: {}
users:
- name: some-user
user:
token: kubeconfig-user-q4lm4:xxxyyyy
`;
describe("<DeleteClusterDialog />", () => {
let builder: ApplicationBuilder;
beforeEach(() => {
builder = getApplicationBuilder();
builder.beforeApplicationStart((mainDi) => {
mainDi.override(createContextHandlerInjectable, () => () => undefined as never);
mainDi.override(createKubeconfigManagerInjectable, () => () => undefined as never);
mainDi.override(kubectlBinaryNameInjectable, () => "kubectl");
mainDi.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64");
mainDi.override(normalizedPlatformInjectable, () => "darwin");
});
builder.beforeWindowStart((windowDi) => {
windowDi.override(storesAndApisCanBeCreatedInjectable, () => true);
});
});
it("shows context switcher when deleting current cluster", async () => {
const config = new KubeConfig();
config.loadFromString(multiClusterConfig);
const rendered = await builder.render();
const windowDi = builder.applicationWindow.only.di;
const createCluster = windowDi.inject(createClusterInjectionToken);
const cluster = createCluster({
id: "some-current-context-cluster",
contextName: "some-current-context",
preferences: {
clusterName: "some-current-context-cluster",
},
kubeConfigPath: "./temp-kube-config",
}, {
clusterServerUrl: currentClusterServerUrl,
});
const openDeleteClusterDialog = windowDi.inject(openDeleteClusterDialogInjectable);
openDeleteClusterDialog({ cluster, config });
const { getByText } = rendered;
const menu = getByText("Select new context...");
expect(menu).toBeInTheDocument();
selectEvent.openMenu(menu);
expect(getByText("some-current-context")).toBeInTheDocument();
expect(getByText("some-non-current-context")).toBeInTheDocument();
});
describe("Kubeconfig with different clusters", () => {
let rendered: RenderResult;
let openDeleteClusterDialog: OpenDeleteClusterDialog;
let createCluster: CreateCluster;
let config: KubeConfig;
beforeEach(async () => {
config = new KubeConfig();
config.loadFromString(multiClusterConfig);
rendered = await builder.render();
const windowDi = builder.applicationWindow.only.di;
openDeleteClusterDialog = windowDi.inject(openDeleteClusterDialogInjectable);
createCluster = windowDi.inject(createClusterInjectionToken);
});
it("renders w/o errors", () => {
expect(rendered.container).toBeInstanceOf(HTMLElement);
});
it("shows warning when deleting non-current-context cluster", () => {
const cluster = createCluster({
id: "some-non-current-context-cluster",
contextName: "some-non-current-context",
preferences: {
clusterName: "minikube",
},
kubeConfigPath: "./temp-kube-config",
}, {
clusterServerUrl: nonCurrentClusterServerUrl,
});
openDeleteClusterDialog({ cluster, config });
const message = "The contents of kubeconfig file will be changed!";
expect(rendered.getByText(message)).toBeInstanceOf(HTMLElement);
});
it("shows warning when deleting current-context cluster", () => {
const cluster = createCluster({
id: "some-current-context-cluster",
contextName: "some-current-context",
preferences: {
clusterName: "some-current-context-cluster",
},
kubeConfigPath: "./temp-kube-config",
}, {
clusterServerUrl: currentClusterServerUrl,
});
openDeleteClusterDialog({ cluster, config });
expect(rendered.getByTestId("current-context-warning")).toBeInstanceOf(HTMLElement);
});
it("shows context switcher after checkbox click", () => {
const cluster = createCluster({
id: "some-current-context-cluster",
contextName: "some-current-context",
preferences: {
clusterName: "some-current-context-cluster",
},
kubeConfigPath: "./temp-kube-config",
}, {
clusterServerUrl: currentClusterServerUrl,
});
openDeleteClusterDialog({ cluster, config });
const { getByText, getByTestId } = rendered;
const link = getByTestId("context-switch");
expect(link).toBeInstanceOf(HTMLElement);
fireEvent.click(link);
const menu = getByText("Select new context...");
expect(menu).toBeInTheDocument();
selectEvent.openMenu(menu);
expect(getByText("some-current-context")).toBeInTheDocument();
expect(getByText("some-non-current-context")).toBeInTheDocument();
});
it("given cluster in internal kubeconfig, when deleting cluster outside of current context, shows warning for internal kubeconfig cluster", () => {
const cluster = createCluster({
id: "some-non-current-context-cluster",
contextName: "some-non-current-context",
preferences: {
clusterName: "some-non-current-context-cluster",
},
kubeConfigPath: "./temp-kube-config",
}, {
clusterServerUrl: nonCurrentClusterServerUrl,
});
const spy = jest.spyOn(cluster, "isInLocalKubeconfig").mockImplementation(() => true);
openDeleteClusterDialog({ cluster, config });
expect(rendered.getByTestId("internal-kubeconfig-warning")).toBeInstanceOf(HTMLElement);
spy.mockRestore();
});
});
describe("Kubeconfig with single cluster", () => {
let rendered: RenderResult;
let openDeleteClusterDialog: OpenDeleteClusterDialog;
let createCluster: CreateCluster;
let config: KubeConfig;
beforeEach(async () => {
config = new KubeConfig();
config.loadFromString(singleClusterConfig);
rendered = await builder.render();
const windowDi = builder.applicationWindow.only.di;
openDeleteClusterDialog = windowDi.inject(openDeleteClusterDialogInjectable);
createCluster = windowDi.inject(createClusterInjectionToken);
});
it("shows warning if no other contexts left", () => {
const cluster = createCluster({
id: "some-cluster",
contextName: "some-context",
preferences: {
clusterName: "some-cluster",
},
kubeConfigPath: "./temp-kube-config",
}, {
clusterServerUrl: singleClusterServerUrl,
});
openDeleteClusterDialog({ cluster, config });
expect(rendered.getByTestId("no-more-contexts-warning")).toBeInstanceOf(HTMLElement);
});
});
});
@@ -17,8 +17,6 @@ const deleteClusterDialogClusterFrameChildComponentInjectable = getInjectable({
}),
injectionToken: clusterFrameChildComponentInjectionToken,
causesSideEffects: true,
});
export default deleteClusterDialogClusterFrameChildComponentInjectable;
@@ -1,21 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { KubeConfig } from "@kubernetes/client-node";
import { dumpYaml } from "@kubernetes/client-node";
import fs from "fs";
import * as lockFile from "proper-lockfile";
export async function saveKubeconfig(config: KubeConfig, path: string) {
try {
const release = await lockFile.lock(path);
const contents = dumpYaml(JSON.parse(config.exportConfig()));
await fs.promises.writeFile(path, contents);
await release();
} catch (e) {
throw new Error(`Failed to acquire lock file.\n${e}`);
}
}
@@ -0,0 +1,11 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getGlobalOverride } from "../../../common/test-utils/get-global-override";
import saveKubeconfigInjectable from "./save-kubeconfig.injectable";
export default getGlobalOverride(saveKubeconfigInjectable, () => async () => {
throw new Error("tried to save a mondified kubeconfig without override");
});
@@ -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 { KubeConfig } from "@kubernetes/client-node";
import { dumpYaml } from "@kubernetes/client-node";
import * as lockFile from "proper-lockfile";
import { getInjectable } from "@ogre-tools/injectable";
import writeFileInjectable from "../../../common/fs/write-file.injectable";
export type SaveKubeconfig = (config: KubeConfig, path: string) => Promise<void>;
const saveKubeconfigInjectable = getInjectable({
id: "save-kubeconfig",
instantiate: (di): SaveKubeconfig => {
const writeFile = di.inject(writeFileInjectable);
return async (config, filePath) => {
const release = await lockFile.lock(filePath);
try {
const contents = dumpYaml(JSON.parse(config.exportConfig()));
await writeFile(filePath, contents);
} finally {
await release();
}
};
},
causesSideEffects: true,
});
export default saveKubeconfigInjectable;
@@ -10,22 +10,34 @@ import { observer } from "mobx-react";
import React from "react";
import { Button } from "../button";
import { saveKubeconfig } from "./save-config";
import { Notifications } from "../notifications";
import type { ShowNotification } from "../notifications";
import { Dialog } from "../dialog";
import { Icon } from "../icon";
import { Select } from "../select";
import { Checkbox } from "../checkbox";
import { requestClearClusterAsDeleting, requestDeleteCluster, requestSetClusterAsDeleting } from "../../ipc";
import type { HotbarStore } from "../../../common/hotbars/store";
import { withInjectables } from "@ogre-tools/injectable-react";
import hotbarStoreInjectable from "../../../common/hotbars/store.injectable";
import type { DeleteClusterDialogState } from "./state.injectable";
import deleteClusterDialogStateInjectable from "./state.injectable";
import type { RequestSetClusterAsDeleting } from "../../../features/cluster/delete-dialog/renderer/request-set-as-deleting.injectable";
import requestSetClusterAsDeletingInjectable from "../../../features/cluster/delete-dialog/renderer/request-set-as-deleting.injectable";
import type { RequestClearClusterAsDeleting } from "../../../features/cluster/delete-dialog/renderer/request-clear-as-deleting.injectable";
import requestClearClusterAsDeletingInjectable from "../../../features/cluster/delete-dialog/renderer/request-clear-as-deleting.injectable";
import type { RequestDeleteCluster } from "../../../features/cluster/delete-dialog/renderer/request-delete.injectable";
import requestDeleteClusterInjectable from "../../../features/cluster/delete-dialog/renderer/request-delete.injectable";
import type { SaveKubeconfig } from "./save-kubeconfig.injectable";
import saveKubeconfigInjectable from "./save-kubeconfig.injectable";
import showErrorNotificationInjectable from "../notifications/show-error-notification.injectable";
export interface Dependencies {
interface Dependencies {
state: IObservableValue<DeleteClusterDialogState | undefined>;
hotbarStore: HotbarStore;
requestSetClusterAsDeleting: RequestSetClusterAsDeleting;
requestDeleteCluster: RequestDeleteCluster;
requestClearClusterAsDeleting: RequestClearClusterAsDeleting;
showErrorNotification: ShowNotification;
saveKubeconfig: SaveKubeconfig;
}
@observer
@@ -61,18 +73,18 @@ class NonInjectedDeleteClusterDialog extends React.Component<Dependencies> {
async onDelete(state: DeleteClusterDialogState) {
const { cluster, config } = state;
await requestSetClusterAsDeleting(cluster.id);
await this.props.requestSetClusterAsDeleting(cluster.id);
this.removeContext(state);
this.changeCurrentContext(state);
try {
await saveKubeconfig(config, cluster.kubeConfigPath);
await this.props.saveKubeconfig(config, cluster.kubeConfigPath);
this.props.hotbarStore.removeAllHotbarItems(cluster.id);
await requestDeleteCluster(cluster.id);
await this.props.requestDeleteCluster(cluster.id);
} catch(error) {
Notifications.error(`Cannot remove cluster, failed to process config file. ${error}`);
this.props.showErrorNotification(`Cannot remove cluster, failed to process config file. ${error}`);
} finally {
await requestClearClusterAsDeleting(cluster.id);
await this.props.requestClearClusterAsDeleting(cluster.id);
}
this.onClose();
@@ -155,6 +167,14 @@ class NonInjectedDeleteClusterDialog extends React.Component<Dependencies> {
}
getWarningMessage({ cluster, config }: DeleteClusterDialogState) {
if (cluster.isInLocalKubeconfig()) {
return (
<p data-testid="internal-kubeconfig-warning">
Are you sure you want to delete it? It can be re-added through the copy/paste mechanism.
</p>
);
}
const contexts = config.contexts.filter(context => context.name !== cluster.contextName);
if (!contexts.length) {
@@ -173,14 +193,6 @@ class NonInjectedDeleteClusterDialog extends React.Component<Dependencies> {
);
}
if (cluster.isInLocalKubeconfig()) {
return (
<p data-testid="internal-kubeconfig-warning">
Are you sure you want to delete it? It can be re-added through the copy/paste mechanism.
</p>
);
}
return (
<p data-testid="kubeconfig-change-warning">The contents of kubeconfig file will be changed!</p>
);
@@ -209,7 +221,7 @@ class NonInjectedDeleteClusterDialog extends React.Component<Dependencies> {
<hr className={styles.hr} />
<div className="mt-4">
<Checkbox
data-testid="context-switch"
data-testid="delete-cluster-dialog-context-switch"
label={(
<>
<span className="font-semibold">Select current-context</span>
@@ -255,6 +267,7 @@ class NonInjectedDeleteClusterDialog extends React.Component<Dependencies> {
close={this.close}
onClose={this.onClose}
onOpen={state && (() => this.onOpen(state))}
data-testid={state ? "delete-cluster-dialog" : undefined}
>
{state && this.renderContents(state)}
</Dialog>
@@ -266,5 +279,10 @@ export const DeleteClusterDialog = withInjectables<Dependencies>(NonInjectedDele
getProps: (di) => ({
hotbarStore: di.inject(hotbarStoreInjectable),
state: di.inject(deleteClusterDialogStateInjectable),
requestSetClusterAsDeleting: di.inject(requestSetClusterAsDeletingInjectable),
requestClearClusterAsDeleting: di.inject(requestClearClusterAsDeletingInjectable),
requestDeleteCluster: di.inject(requestDeleteClusterInjectable),
saveKubeconfig: di.inject(saveKubeconfigInjectable),
showErrorNotification: di.inject(showErrorNotificationInjectable),
}),
});
+2 -4
View File
@@ -146,13 +146,11 @@ class NonInjectedDialog extends React.PureComponent<DialogProps & Dependencies &
};
render() {
const { modal, animated, pinned, "data-testid": testId } = this.props;
let { className } = this.props;
const { modal, animated, pinned, "data-testid": testId, className } = this.props;
className = cssNames("Dialog flex center", className, { modal, pinned });
let dialog = (
<div
className={className}
className={cssNames("Dialog flex center", className, { modal, pinned })}
onClick={stopPropagation}
ref={this.ref}
data-testid={testId}
@@ -3,49 +3,35 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import path from "path";
import { hasCorrectExtension } from "./has-correct-extension";
import readFileInjectable from "../../../../common/fs/read-file.injectable";
import readDirInjectable from "../../../../common/fs/read-dir.injectable";
import readDirectoryInjectable from "../../../../common/fs/read-directory.injectable";
import type { RawTemplates } from "./create-resource-templates.injectable";
import type { GetAbsolutePath } from "../../../../common/path/get-absolute-path.injectable";
import getAbsolutePathInjectable from "../../../../common/path/get-absolute-path.injectable";
import staticFilesDirectoryInjectable from "../../../../common/vars/static-files-directory.injectable";
interface Dependencies {
readDir: (dirPath: string) => Promise<string[]>;
readFile: (filePath: string) => Promise<string>;
getAbsolutePath: GetAbsolutePath;
staticFilesDirectory: string;
}
async function getTemplates({ readDir, readFile, getAbsolutePath, staticFilesDirectory }: Dependencies) {
const templatesFolder = getAbsolutePath(staticFilesDirectory, "../templates/create-resource");
/**
* Mapping between file names and their contents
*/
const templates: [file: string, contents: string][] = [];
for (const dirEntry of await readDir(templatesFolder)) {
if (hasCorrectExtension(dirEntry)) {
templates.push([path.parse(dirEntry).name, await readFile(path.join(templatesFolder, dirEntry))]);
}
}
return templates;
}
import joinPathsInjectable from "../../../../common/path/join-paths.injectable";
import parsePathInjectable from "../../../../common/path/parse.injectable";
const lensCreateResourceTemplatesInjectable = getInjectable({
id: "lens-create-resource-templates",
instantiate: async (di): Promise<RawTemplates> => {
const templates = await getTemplates({
readFile: di.inject(readFileInjectable),
readDir: di.inject(readDirInjectable),
getAbsolutePath: di.inject(getAbsolutePathInjectable),
staticFilesDirectory: di.inject(staticFilesDirectoryInjectable),
});
const readFile = di.inject(readFileInjectable);
const readDir = di.inject(readDirectoryInjectable);
const joinPaths = di.inject(joinPathsInjectable);
const staticFilesDirectory = di.inject(staticFilesDirectoryInjectable);
const parsePath = di.inject(parsePathInjectable);
/**
* Mapping between file names and their contents
*/
const templates: [file: string, contents: string][] = [];
const templatesFolder = joinPaths(staticFilesDirectory, "../templates/create-resource");
for (const dirEntry of await readDir(templatesFolder)) {
if (hasCorrectExtension(dirEntry)) {
templates.push([parsePath(dirEntry).name, await readFile(joinPaths(templatesFolder, dirEntry))]);
}
}
return ["lens", templates];
},
@@ -3,101 +3,108 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { IComputedValue } from "mobx";
import { computed, observable } from "mobx";
import path from "path";
import os from "os";
import { delay, getOrInsert, isErrnoException, waitForPath } from "../../../utils";
import { watch } from "chokidar";
import { readFile } from "fs/promises";
import logger from "../../../../common/logger";
import { hasCorrectExtension } from "./has-correct-extension";
import type { RawTemplates } from "./create-resource-templates.injectable";
const userTemplatesFolder = path.join(os.homedir(), ".k8slens", "templates");
function groupTemplates(templates: Map<string, string>): RawTemplates[] {
const res = new Map<string, [string, string][]>();
for (const [filePath, contents] of templates) {
const rawRelative = path.dirname(path.relative(userTemplatesFolder, filePath));
const title = rawRelative === "."
? "ungrouped"
: rawRelative;
getOrInsert(res, title, []).push([path.parse(filePath).name, contents]);
}
return [...res.entries()];
}
function watchUserCreateResourceTemplates(): IComputedValue<RawTemplates[]> {
/**
* Map between filePaths and template contents
*/
const templates = observable.map<string, string>();
const onAddOrChange = async (filePath: string) => {
if (!hasCorrectExtension(filePath)) {
// ignore non yaml or json files
return;
}
try {
const contents = await readFile(filePath, "utf-8");
templates.set(filePath, contents);
} catch (error) {
if (isErrnoException(error) && error.code === "ENOENT") {
// ignore, file disappeared
} else {
logger.warn(`[USER-CREATE-RESOURCE-TEMPLATES]: encountered error while reading ${filePath}`, error);
}
}
};
const onUnlink = (filePath: string) => {
templates.delete(filePath);
};
(async () => {
for (let i = 1;; i *= 2) {
try {
await waitForPath(userTemplatesFolder);
break;
} catch (error) {
logger.warn(`[USER-CREATE-RESOURCE-TEMPLATES]: encountered error while waiting for ${userTemplatesFolder} to exist, waiting and trying again`, error);
await delay(i * 1000); // exponential backoff in seconds
}
}
/**
* NOTE: There is technically a race condition here of the form "time-of-check to time-of-use"
*/
watch(userTemplatesFolder, {
disableGlobbing: true,
ignorePermissionErrors: true,
usePolling: false,
awaitWriteFinish: {
pollInterval: 100,
stabilityThreshold: 1000,
},
ignoreInitial: false,
atomic: 150, // for "atomic writes"
})
.on("add", onAddOrChange)
.on("change", onAddOrChange)
.on("unlink", onUnlink)
.on("error", error => {
logger.warn(`[USER-CREATE-RESOURCE-TEMPLATES]: encountered error while watching files under ${userTemplatesFolder}`, error);
});
})();
return computed(() => groupTemplates(templates));
}
import joinPathsInjectable from "../../../../common/path/join-paths.injectable";
import watchInjectable from "../../../../common/fs/watch/watch.injectable";
import getRelativePathInjectable from "../../../../common/path/get-relative-path.injectable";
import homeDirectoryPathInjectable from "../../../../common/os/home-directory-path.injectable";
import getDirnameOfPathInjectable from "../../../../common/path/get-dirname.injectable";
import loggerInjectable from "../../../../common/logger.injectable";
import parsePathInjectable from "../../../../common/path/parse.injectable";
const userCreateResourceTemplatesInjectable = getInjectable({
id: "user-create-resource-templates",
instantiate: () => watchUserCreateResourceTemplates(),
instantiate: (di) => {
const joinPaths = di.inject(joinPathsInjectable);
const watch = di.inject(watchInjectable);
const getRelativePath = di.inject(getRelativePathInjectable);
const homeDirectoryPath = di.inject(homeDirectoryPathInjectable);
const getDirnameOfPath = di.inject(getDirnameOfPathInjectable);
const logger = di.inject(loggerInjectable);
const parsePath = di.inject(parsePathInjectable);
const userTemplatesFolder = joinPaths(homeDirectoryPath, ".k8slens", "templates");
const groupTemplates = (templates: Map<string, string>): RawTemplates[] => {
const res = new Map<string, [string, string][]>();
for (const [filePath, contents] of templates) {
const rawRelative = getDirnameOfPath(getRelativePath(userTemplatesFolder, filePath));
const title = rawRelative === "."
? "ungrouped"
: rawRelative;
getOrInsert(res, title, []).push([parsePath(filePath).name, contents]);
}
return [...res.entries()];
};
/**
* Map between filePaths and template contents
*/
const templates = observable.map<string, string>();
const onAddOrChange = async (filePath: string) => {
if (!hasCorrectExtension(filePath)) {
// ignore non yaml or json files
return;
}
try {
const contents = await readFile(filePath, "utf-8");
templates.set(filePath, contents);
} catch (error) {
if (isErrnoException(error) && error.code === "ENOENT") {
// ignore, file disappeared
} else {
logger.warn(`[USER-CREATE-RESOURCE-TEMPLATES]: encountered error while reading ${filePath}`, error);
}
}
};
const onUnlink = (filePath: string) => {
templates.delete(filePath);
};
(async () => {
for (let i = 1;; i *= 2) {
try {
await waitForPath(userTemplatesFolder);
break;
} catch (error) {
logger.warn(`[USER-CREATE-RESOURCE-TEMPLATES]: encountered error while waiting for ${userTemplatesFolder} to exist, waiting and trying again`, error);
await delay(i * 1000); // exponential backoff in seconds
}
}
/**
* NOTE: There is technically a race condition here of the form "time-of-check to time-of-use"
*/
watch(userTemplatesFolder, {
disableGlobbing: true,
ignorePermissionErrors: true,
usePolling: false,
awaitWriteFinish: {
pollInterval: 100,
stabilityThreshold: 1000,
},
ignoreInitial: false,
atomic: 150, // for "atomic writes"
})
.on("add", onAddOrChange)
.on("change", onAddOrChange)
.on("unlink", onUnlink)
.on("error", error => {
logger.warn(`[USER-CREATE-RESOURCE-TEMPLATES]: encountered error while watching files under ${userTemplatesFolder}`, error);
});
})();
return computed(() => groupTemplates(templates));
},
});
export default userCreateResourceTemplatesInjectable;
@@ -6,14 +6,13 @@
import React from "react";
import type { SelectOption } from "../../select";
import { Select } from "../../select";
import yaml from "js-yaml";
import yaml, { dump } from "js-yaml";
import type { IComputedValue } from "mobx";
import { makeObservable, observable } from "mobx";
import { observer } from "mobx-react";
import type { CreateResourceTabStore } from "./store";
import { EditorPanel } from "../editor-panel";
import { InfoPanel } from "../info-panel";
import * as resourceApplierApi from "../../../../common/k8s-api/endpoints/resource-applier.api";
import { Notifications } from "../../notifications";
import logger from "../../../../common/logger";
import type { ApiManager } from "../../../../common/k8s-api/api-manager";
@@ -28,6 +27,8 @@ import type { GetDetailsUrl } from "../../kube-detail-params/get-details-url.inj
import apiManagerInjectable from "../../../../common/k8s-api/api-manager/manager.injectable";
import getDetailsUrlInjectable from "../../kube-detail-params/get-details-url.injectable";
import navigateInjectable from "../../../navigation/navigate.injectable";
import type { RequestKubeObjectCreation } from "../../../../common/k8s-api/endpoints/resource-applier.api/request-update.injectable";
import requestKubeObjectCreationInjectable from "../../../../common/k8s-api/endpoints/resource-applier.api/request-update.injectable";
export interface CreateResourceProps {
tabId: string;
@@ -39,6 +40,7 @@ interface Dependencies {
apiManager: ApiManager;
navigate: Navigate;
getDetailsUrl: GetDetailsUrl;
requestKubeObjectCreation: RequestKubeObjectCreation;
}
@observer
@@ -68,7 +70,7 @@ class NonInjectedCreateResource extends React.Component<CreateResourceProps & De
};
create = async (): Promise<void> => {
const { apiManager, getDetailsUrl, navigate } = this.props;
const { apiManager, getDetailsUrl, navigate, requestKubeObjectCreation } = this.props;
if (this.error || !this.data?.trim()) {
// do not save when field is empty or there is an error
@@ -84,7 +86,7 @@ class NonInjectedCreateResource extends React.Component<CreateResourceProps & De
const creatingResources = resources.map(async (resource) => {
try {
const data = await resourceApplierApi.update(resource);
const data = await requestKubeObjectCreation(dump(resource));
const { kind, apiVersion, metadata: { name, namespace }} = data;
const showDetails = () => {
@@ -168,5 +170,6 @@ export const CreateResource = withInjectables<Dependencies, CreateResourceProps>
apiManager: di.inject(apiManagerInjectable),
getDetailsUrl: di.inject(getDetailsUrlInjectable),
navigate: di.inject(navigateInjectable),
requestKubeObjectCreation: di.inject(requestKubeObjectCreationInjectable),
}),
});
@@ -103,6 +103,7 @@ class NonInjectedDockTab extends React.Component<DockTabProps & Dependencies> {
material="close"
tooltip={`Close ${this.props.isMac ? "⌘+W" : "Ctrl+W"}`}
onClick={close}
data-testid={`dock-tab-close-for-${id}`}
/>
</div>
)}
@@ -1,20 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { getChartValues } from "../../../../../common/k8s-api/endpoints/helm-charts.api";
export type CallForHelmChartValues = (
repo: string,
name: string,
version: string
) => Promise<string>;
const callForHelmChartValuesInjectable = getInjectable({
id: "call-for-helm-chart-values",
instantiate: (): CallForHelmChartValues => getChartValues,
causesSideEffects: true,
});
export default callForHelmChartValuesInjectable;
@@ -5,36 +5,31 @@
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import installChartTabStoreInjectable from "./store.injectable";
import { waitUntilDefined } from "../../../../common/utils";
import type { CallForHelmChartValues } from "./chart-data/call-for-helm-chart-values.injectable";
import callForHelmChartValuesInjectable from "./chart-data/call-for-helm-chart-values.injectable";
import type { IChartInstallData, InstallChartTabStore } from "./store";
import type { HelmChart } from "../../../../common/k8s-api/endpoints/helm-charts.api";
import React from "react";
import {
action,
computed,
observable,
runInAction,
} from "mobx";
import { action, computed, observable, runInAction } from "mobx";
import assert from "assert";
import type { CallForCreateHelmRelease } from "../../+helm-releases/create-release/call-for-create-helm-release.injectable";
import callForCreateHelmReleaseInjectable from "../../+helm-releases/create-release/call-for-create-helm-release.injectable";
import type { RequestCreateHelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api/request-create.injectable";
import requestCreateHelmReleaseInjectable from "../../../../common/k8s-api/endpoints/helm-releases.api/request-create.injectable";
import type { HelmReleaseUpdateDetails } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import dockStoreInjectable from "../dock/store.injectable";
import type { NavigateToHelmReleases } from "../../../../common/front-end-routing/routes/cluster/helm/releases/navigate-to-helm-releases.injectable";
import navigateToHelmReleasesInjectable from "../../../../common/front-end-routing/routes/cluster/helm/releases/navigate-to-helm-releases.injectable";
import type { SingleValue } from "react-select";
import type { CallForHelmChartVersions } from "../../+helm-charts/details/versions/call-for-helm-chart-versions.injectable";
import callForHelmChartVersionsInjectable from "../../+helm-charts/details/versions/call-for-helm-chart-versions.injectable";
import type { RequestHelmChartValues } from "../../../../common/k8s-api/endpoints/helm-charts.api/request-values.injectable";
import requestHelmChartValuesInjectable from "../../../../common/k8s-api/endpoints/helm-charts.api/request-values.injectable";
import type { RequestHelmChartVersions } from "../../../../common/k8s-api/endpoints/helm-charts.api/request-versions.injectable";
import requestHelmChartVersionsInjectable from "../../../../common/k8s-api/endpoints/helm-charts.api/request-versions.injectable";
const installChartModelInjectable = getInjectable({
id: "install-chart-model",
instantiate: async (di, tabId: string) => {
const store = di.inject(installChartTabStoreInjectable);
const callForHelmChartValues = di.inject(callForHelmChartValuesInjectable);
const callForHelmChartVersions = di.inject(callForHelmChartVersionsInjectable);
const callForCreateHelmRelease = di.inject(callForCreateHelmReleaseInjectable);
const requestHelmChartValues = di.inject(requestHelmChartValuesInjectable);
const requestHelmChartVersions = di.inject(requestHelmChartVersionsInjectable);
const callForCreateHelmRelease = di.inject(requestCreateHelmReleaseInjectable);
const dockStore = di.inject(dockStoreInjectable);
const navigateToHelmReleases = di.inject(navigateToHelmReleasesInjectable);
const closeTab = () => dockStore.closeTab(tabId);
@@ -49,8 +44,8 @@ const installChartModelInjectable = getInjectable({
callForCreateHelmRelease,
closeTab,
navigateToHelmReleases,
callForHelmChartValues,
callForHelmChartVersions,
requestHelmChartValues,
requestHelmChartVersions,
store,
});
@@ -71,9 +66,9 @@ interface Dependencies {
closeTab: () => void;
navigateToHelmReleases: NavigateToHelmReleases;
waitForChart: () => Promise<void>;
callForCreateHelmRelease: CallForCreateHelmRelease;
callForHelmChartValues: CallForHelmChartValues;
callForHelmChartVersions: CallForHelmChartVersions;
callForCreateHelmRelease: RequestCreateHelmRelease;
requestHelmChartValues: RequestHelmChartValues;
requestHelmChartVersions: RequestHelmChartVersions;
store: InstallChartTabStore;
}
@@ -127,7 +122,7 @@ export class InstallChartModel {
this.configuration.isLoading.set(true);
});
const configuration = await this.dependencies.callForHelmChartValues(
const configuration = await this.dependencies.requestHelmChartValues(
this.chart.repo,
this.chart.name,
version,
@@ -193,13 +188,13 @@ export class InstallChartModel {
await this.dependencies.waitForChart();
const [defaultConfiguration, versions] = await Promise.all([
this.dependencies.callForHelmChartValues(
this.dependencies.requestHelmChartValues(
this.chart.repo,
this.chart.name,
this.chart.version,
),
this.dependencies.callForHelmChartVersions(
this.dependencies.requestHelmChartVersions(
this.chart.repo,
this.chart.name,
),
@@ -3,23 +3,27 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { IChartUpgradeData } from "./store.injectable";
import upgradeChartTabStoreInjectable from "./store.injectable";
import dockStoreInjectable from "../dock/store.injectable";
import type { HelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import type { DockStore, DockTabCreateSpecific, TabId } from "../dock/store";
import { TabKind } from "../dock/store";
import type { UpgradeChartTabStore } from "./store";
import { runInAction } from "mobx";
import getRandomUpgradeChartTabIdInjectable from "./get-random-upgrade-chart-tab-id.injectable";
import type { DockTabStore } from "../dock-tab-store/dock-tab.store";
interface Dependencies {
upgradeChartStore: UpgradeChartTabStore;
upgradeChartStore: DockTabStore<IChartUpgradeData>;
dockStore: DockStore;
getRandomId: () => string;
}
const createUpgradeChartTab = ({ upgradeChartStore, dockStore, getRandomId }: Dependencies) => (release: HelmRelease, tabParams: DockTabCreateSpecific = {}): TabId => {
const tabId = upgradeChartStore.getTabIdByRelease(release.getName());
const tabId = upgradeChartStore.findTabIdFromData(val => (
val.releaseName === release.getName()
&& val.releaseNamespace === release.getNs()
));
if (tabId) {
dockStore.open();
@@ -3,9 +3,12 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { UpgradeChartTabStore } from "./store";
import createDockTabStoreInjectable from "../dock-tab-store/create-dock-tab-store.injectable";
import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable";
export interface IChartUpgradeData {
releaseName: string;
releaseNamespace: string;
}
const upgradeChartTabStoreInjectable = getInjectable({
id: "upgrade-chart-tab-store",
@@ -13,9 +16,8 @@ const upgradeChartTabStoreInjectable = getInjectable({
instantiate: (di) => {
const createDockTabStore = di.inject(createDockTabStoreInjectable);
return new UpgradeChartTabStore({
createStorage: di.inject(createStorageInjectable),
valuesStore: createDockTabStore<string>(),
return createDockTabStore<IChartUpgradeData>({
storageKey: "chart_releases",
});
},
});
@@ -1,55 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { action, computed, makeObservable } from "mobx";
import type { TabId } from "../dock/store";
import type { DockTabStoreDependencies } from "../dock-tab-store/dock-tab.store";
import { DockTabStore } from "../dock-tab-store/dock-tab.store";
import { getReleaseValues } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import assert from "assert";
export interface IChartUpgradeData {
releaseName: string;
releaseNamespace: string;
}
export interface UpgradeChartTabStoreDependencies extends DockTabStoreDependencies {
valuesStore: DockTabStore<string>;
}
export class UpgradeChartTabStore extends DockTabStore<IChartUpgradeData> {
@computed private get releaseNameReverseLookup(): Map<string, string> {
return new Map(this.getAllData().map(([id, { releaseName }]) => [releaseName, id]));
}
get values() {
return this.dependencies.valuesStore;
}
constructor(protected readonly dependencies: UpgradeChartTabStoreDependencies) {
super(dependencies, {
storageKey: "chart_releases",
});
makeObservable(this);
}
@action
async reloadValues(tabId: TabId) {
this.values.clearData(tabId); // reset
const data = this.getData(tabId);
assert(data, "cannot reload values if no data");
const { releaseName, releaseNamespace } = data;
const values = await getReleaseValues(releaseName, releaseNamespace, true);
this.values.setData(tabId, values);
}
getTabIdByRelease(releaseName: string) {
return this.releaseNameReverseLookup.get(releaseName);
}
}
@@ -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, lifecycleEnum } from "@ogre-tools/injectable";
import { waitUntilDefined } from "../../../utils";
import upgradeChartTabStoreInjectable from "./store.injectable";
const upgradeChartTabDataInjectable = getInjectable({
id: "upgrade-chart-tab-data",
instantiate: (di, tabId) => {
const upgradeChartTabStore = di.inject(upgradeChartTabStoreInjectable);
return waitUntilDefined(() => upgradeChartTabStore.getData(tabId));
},
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, tabId: string) => tabId,
}),
});
export default upgradeChartTabDataInjectable;
@@ -0,0 +1,129 @@
/**
* 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 { asyncComputed } from "@ogre-tools/injectable-react";
import type { IComputedValue } from "mobx";
import { action, computed, observable, when } from "mobx";
import type { SingleValue } from "react-select";
import type { HelmChartVersion } from "../../+helm-charts/helm-charts/versions";
import helmChartVersionsInjectable from "../../+helm-charts/helm-charts/versions.injectable";
import releasesInjectable from "../../+helm-releases/releases.injectable";
import updateReleaseInjectable from "../../+helm-releases/update-release/update-release.injectable";
import type { HelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import requestHelmReleaseConfigurationInjectable from "../../../../common/k8s-api/endpoints/helm-releases.api/request-configuration.injectable";
import { waitUntilDefined } from "../../../utils";
import type { SelectOption } from "../../select";
import type { DockTab } from "../dock/store";
import upgradeChartTabDataInjectable from "./tab-data.injectable";
export interface UpgradeChartModel {
readonly release: HelmRelease;
readonly versionOptions: IComputedValue<SelectOption<HelmChartVersion>[]>;
readonly configration: {
readonly value: IComputedValue<string>;
set: (value: string) => void;
readonly error: IComputedValue<string | undefined>;
setError: (error: unknown) => void;
};
readonly version: {
readonly value: IComputedValue<HelmChartVersion | undefined>;
set: (value: SingleValue<SelectOption<HelmChartVersion>>) => void;
};
submit: () => Promise<UpgradeChartSubmitResult>;
}
export interface UpgradeChartSubmitResult {
completedSuccessfully: boolean;
}
const upgradeChartModelInjectable = getInjectable({
id: "upgrade-chart-model",
instantiate: async (di, tab): Promise<UpgradeChartModel> => {
const releases = di.inject(releasesInjectable);
const requestHelmReleaseConfiguration = di.inject(requestHelmReleaseConfigurationInjectable);
const updateRelease = di.inject(updateReleaseInjectable);
const tabData = await di.inject(upgradeChartTabDataInjectable, tab.id);
const release = await waitUntilDefined(() => (
releases.value
.get()
.find(release => (
release.getName() === tabData.releaseName
&& release.getNs() === tabData.releaseNamespace
))
));
const versions = di.inject(helmChartVersionsInjectable, release);
const storedConfiguration = asyncComputed(() => requestHelmReleaseConfiguration(
release.getName(),
release.getNs(),
true,
), "");
await when(() => !versions.pending.get());
const configrationValue = observable.box<string>();
const configrationEditError = observable.box<string>();
const configration: UpgradeChartModel["configration"] = {
value: computed(() => configrationValue.get() ?? storedConfiguration.value.get()),
set: action((value) => {
configrationValue.set(value);
configrationEditError.set(undefined);
}),
error: computed(() => configrationEditError.get()),
setError: action((error) => configrationEditError.set(String(error))),
};
const versionValue = observable.box<HelmChartVersion>(undefined, {
deep: false,
});
const version: UpgradeChartModel["version"] = {
value: computed(() => versionValue.get() ?? versions.value.get()[0]),
set: action((option) => versionValue.set(option?.value)),
};
const versionOptions = computed(() => (
versions.value
.get()
.map(version => ({
value: version,
label: `${version.repo}/${release.getChart()}-${version.version}`,
}))
));
return {
release,
versionOptions,
configration,
version,
submit: async () => {
const selectedVersion = version.value.get();
if (!selectedVersion || configrationEditError.get()) {
return {
completedSuccessfully: false,
};
}
await updateRelease(
release.getName(),
release.getNs(),
{
chart: release.getChart(),
values: configration.value.get(),
...selectedVersion,
},
);
storedConfiguration.invalidate();
return {
completedSuccessfully: true,
};
},
};
},
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, tab: DockTab) => tab.id,
}),
});
export default upgradeChartModelInjectable;
@@ -6,26 +6,19 @@
import "./upgrade-chart.scss";
import React from "react";
import { action, makeObservable, observable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { observer } from "mobx-react";
import { cssNames } from "../../../utils";
import type { DockTab } from "../dock/store";
import { InfoPanel } from "../info-panel";
import type { UpgradeChartTabStore } from "./store";
import { Spinner } from "../../spinner";
import { Badge } from "../../badge";
import { EditorPanel } from "../editor-panel";
import { helmChartStore, type ChartVersion } from "../../+helm-charts/helm-chart.store";
import type { HelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import type { SelectOption } from "../../select";
import { Select } from "../../select";
import type { IAsyncComputed } from "@ogre-tools/injectable-react";
import { withInjectables } from "@ogre-tools/injectable-react";
import upgradeChartTabStoreInjectable from "./store.injectable";
import updateReleaseInjectable from "../../+helm-releases/update-release/update-release.injectable";
import releasesInjectable from "../../+helm-releases/releases.injectable";
import type { CallForHelmReleaseUpdate } from "../../+helm-releases/update-release/call-for-helm-release-update/call-for-helm-release-update.injectable";
import { first } from "lodash/fp";
import type { UpgradeChartModel } from "./upgrade-chart-model.injectable";
import upgradeChartModelInjectable from "./upgrade-chart-model.injectable";
import type { HelmChartVersion } from "../../+helm-charts/helm-charts/versions";
export interface UpgradeChartProps {
className?: string;
@@ -33,159 +26,74 @@ export interface UpgradeChartProps {
}
interface Dependencies {
releases: IAsyncComputed<HelmRelease[]>;
upgradeChartTabStore: UpgradeChartTabStore;
updateRelease: CallForHelmReleaseUpdate;
model: UpgradeChartModel;
}
@observer
export class NonInjectedUpgradeChart extends React.Component<UpgradeChartProps & Dependencies> {
@observable error?: string;
@observable versions = observable.array<ChartVersion>();
@observable version: ChartVersion | undefined = undefined;
constructor(props: UpgradeChartProps & Dependencies) {
super(props);
makeObservable(this);
}
componentDidMount() {
disposeOnUnmount(this, [
reaction(
() => this.release,
release => this.reloadVersions(release),
{
fireImmediately: true,
},
),
reaction(
() => this.release?.getRevision(),
() => this.reloadValues(),
{
fireImmediately: true,
},
),
]);
}
get tabId() {
return this.props.tab.id;
}
get release() {
const tabData = this.props.upgradeChartTabStore.getData(this.tabId);
if (!tabData) return null;
return this.props.releases.value.get().find(release => release.getName() === tabData.releaseName);
}
get value() {
return this.props.upgradeChartTabStore.values.getData(this.tabId);
}
async reloadValues() {
this.props.upgradeChartTabStore.reloadValues(this.props.tab.id);
}
async reloadVersions(release: HelmRelease | null | undefined) {
if (!release) {
return;
}
this.version = undefined;
this.versions.clear();
const versions = await helmChartStore.getVersions(release.getChart());
this.versions.replace(versions);
this.version = first(this.versions);
}
onChange = action((value: string) => {
this.error = "";
this.props.upgradeChartTabStore.values.setData(this.tabId, value);
});
onError = action((error: Error | string) => {
this.error = error.toString();
});
upgrade = async () => {
if (this.error || !this.release || !this.version || !this.value) {
return null;
const { model } = this.props;
const { completedSuccessfully } = await model.submit();
if (completedSuccessfully) {
return (
<p>
{"Release "}
<b>{model.release.getName()}</b>
{" successfully upgraded to version "}
<b>{model.version.value.get()}</b>
</p>
);
}
const { version, repo } = this.version;
const releaseName = this.release.getName();
const releaseNs = this.release.getNs();
await this.props.updateRelease(releaseName, releaseNs, {
chart: this.release.getChart(),
values: this.value,
repo, version,
});
return (
<p>
{"Release "}
<b>{releaseName}</b>
{" successfully upgraded to version "}
<b>{version}</b>
</p>
);
return null;
};
render() {
const { tabId, release, value, error, onChange, onError, upgrade, versions, version } = this;
const { className } = this.props;
if (!release || !this.props.upgradeChartTabStore.isReady(tabId) || !version) {
return <Spinner center />;
}
const currentVersion = release.getVersion();
const versionOptions = versions.map(version => ({
value: version,
label: `${version.repo}/${release.getChart()}-${version.version}`,
}));
const controlsAndInfo = (
<div className="upgrade flex gaps align-center">
<span>Release</span>
{" "}
<Badge label={release.getName()} />
<span>Namespace</span>
{" "}
<Badge label={release.getNs()} />
<span>Version</span>
{" "}
<Badge label={currentVersion} />
<span>Upgrade version</span>
<Select<ChartVersion, SelectOption<ChartVersion>, false>
id="char-version-input"
className="chart-version"
menuPlacement="top"
themeName="outlined"
value={version}
options={versionOptions}
onChange={option => this.version = option?.value}
/>
</div>
);
const { model, className, tab } = this.props;
const tabId = tab.id;
const { release } = model;
return (
<div className={cssNames("UpgradeChart flex column", className)}>
<div
className={cssNames("UpgradeChart flex column", className)}
data-testid={`upgrade-chart-dock-tab-contents-for-${release.getId()}`}
>
<InfoPanel
tabId={tabId}
error={error}
submit={upgrade}
error={model.configration.error.get()}
submit={this.upgrade}
submitLabel="Upgrade"
submittingMessage="Updating.."
controls={controlsAndInfo}
controls={(
<div className="upgrade flex gaps align-center">
<span>Release</span>
{" "}
<Badge label={release.getName()} />
<span>Namespace</span>
{" "}
<Badge label={release.getNs()} />
<span>Version</span>
{" "}
<Badge label={release.getVersion()} />
<span>Upgrade version</span>
<Select<HelmChartVersion, SelectOption<HelmChartVersion>, false>
id="char-version-input"
className="chart-version"
menuPlacement="top"
themeName="outlined"
value={model.version.value.get()}
options={model.versionOptions.get()}
onChange={model.version.set}
/>
</div>
)}
/>
<EditorPanel
tabId={tabId}
value={value ?? ""}
onChange={onChange}
onError={onError}
value={model.configration.value.get()}
onChange={model.configration.set}
onError={model.configration.setError}
/>
</div>
);
@@ -193,10 +101,9 @@ export class NonInjectedUpgradeChart extends React.Component<UpgradeChartProps &
}
export const UpgradeChart = withInjectables<Dependencies, UpgradeChartProps>(NonInjectedUpgradeChart, {
getProps: (di, props) => ({
releases: di.inject(releasesInjectable),
updateRelease: di.inject(updateReleaseInjectable),
upgradeChartTabStore: di.inject(upgradeChartTabStoreInjectable),
getPlaceholder: () => <Spinner center />,
getProps: async (di, props) => ({
...props,
model: await di.inject(upgradeChartModelInjectable, props.tab),
}),
});
@@ -5,121 +5,95 @@
import styles from "./kubeconfig-dialog.module.scss";
import React from "react";
import { makeObservable, observable } from "mobx";
import type { IObservableValue } from "mobx";
import { observer } from "mobx-react";
import yaml from "js-yaml";
import type { ServiceAccount } from "../../../common/k8s-api/endpoints";
import { cssNames, saveFileDialog } from "../../utils";
import { Button } from "../button";
import type { DialogProps } from "../dialog";
import { Dialog } from "../dialog";
import { Icon } from "../icon";
import { Notifications } from "../notifications";
import type { ShowNotification } from "../notifications";
import { Wizard, WizardStep } from "../wizard";
import { apiBase } from "../../api";
import { MonacoEditor } from "../monaco-editor";
import { clipboard } from "electron";
import { withInjectables } from "@ogre-tools/injectable-react";
import showSuccessNotificationInjectable from "../notifications/show-success-notification.injectable";
import kubeconfigDialogStateInjectable from "./state.injectable";
export interface KubeconfigDialogData {
title?: React.ReactNode;
loader: () => Promise<any>;
config: string;
}
export interface KubeConfigDialogProps extends Partial<DialogProps> {
}
const dialogState = observable.box<KubeconfigDialogData | undefined>();
interface Dependencies {
state: IObservableValue<KubeconfigDialogData | undefined>;
showSuccessNotification: ShowNotification;
}
@observer
export class KubeConfigDialog extends React.Component<KubeConfigDialogProps> {
@observable config = ""; // parsed kubeconfig in yaml format
constructor(props: KubeConfigDialogProps) {
class NonInjectedKubeConfigDialog extends React.Component<KubeConfigDialogProps & Dependencies> {
constructor(props: KubeConfigDialogProps & Dependencies) {
super(props);
makeObservable(this);
}
static open(data: KubeconfigDialogData) {
dialogState.set(data);
}
static close() {
dialogState.set(undefined);
}
close = () => {
KubeConfigDialog.close();
this.props.state.set(undefined);
};
onOpen = (data: KubeconfigDialogData) => {
this.loadConfig(data);
copyToClipboard = (config: string) => {
clipboard.writeText(config);
this.props.showSuccessNotification("Config copied to clipboard");
};
async loadConfig(data: KubeconfigDialogData) {
const config = await data.loader().catch(err => {
Notifications.error(err);
this.close();
});
this.config = config ? yaml.dump(config) : "";
}
copyToClipboard = () => {
clipboard.writeText(this.config);
Notifications.ok("Config copied to clipboard");
download = (config: string) => {
saveFileDialog("config", config, "text/yaml");
};
download = () => {
saveFileDialog("config", this.config, "text/yaml");
};
renderContents(data: KubeconfigDialogData) {
const yamlConfig = this.config;
return (
<Wizard header={<h5>{data.title || "Kubeconfig File"}</h5>}>
<WizardStep
customButtons={(
<div className="actions flex gaps">
<Button plain onClick={this.copyToClipboard}>
<Icon material="assignment"/>
{" Copy to clipboard"}
</Button>
<Button plain onClick={this.download}>
<Icon material="cloud_download"/>
{" Download file"}
</Button>
<Button
plain
className="box right"
onClick={this.close}
>
Close
</Button>
</div>
)}
prev={this.close}
>
<MonacoEditor
readOnly
className={styles.editor}
value={yamlConfig}
/>
</WizardStep>
</Wizard>
);
}
renderContents = (data: KubeconfigDialogData) => (
<Wizard header={<h5>{ data.title || "Kubeconfig File" }</h5>}>
<WizardStep
customButtons={ (
<div className="actions flex gaps">
<Button plain onClick={() => this.copyToClipboard(data.config)}>
<Icon material="assignment" />
{" Copy to clipboard"}
</Button>
<Button plain onClick={() => this.download(data.config)}>
<Icon material="cloud_download" />
{" Download file"}
</Button>
<Button
plain
className="box right"
onClick={this.close}
>
Close
</Button>
</div>
) }
prev={this.close}
>
<MonacoEditor
readOnly
className={styles.editor}
value={data.config}
/>
</WizardStep>
</Wizard>
);
render() {
const { className, ...dialogProps } = this.props;
const data = dialogState.get();
const { className, state, ...dialogProps } = this.props;
const data = state.get();
return (
<Dialog
{...dialogProps}
className={cssNames(styles.KubeConfigDialog, className)}
isOpen={Boolean(data)}
onOpen={data && (() => this.onOpen(data))}
isOpen={!!data}
close={this.close}
>
{data && this.renderContents(data)}
@@ -128,12 +102,10 @@ export class KubeConfigDialog extends React.Component<KubeConfigDialogProps> {
}
}
export function openServiceAccountKubeConfig(account: ServiceAccount) {
const accountName = account.getName();
const namespace = account.getNs();
KubeConfigDialog.open({
title: `${accountName} kubeconfig`,
loader: () => apiBase.get(`/kubeconfig/service-account/${namespace}/${accountName}`),
});
}
export const KubeConfigDialog = withInjectables<Dependencies, KubeConfigDialogProps>(NonInjectedKubeConfigDialog, {
getProps: (di, props) => ({
...props,
showSuccessNotification: di.inject(showSuccessNotificationInjectable),
state: di.inject(kubeconfigDialogStateInjectable),
}),
});
Loaded 100 of 367 files, more files were not shown because too many files have changed in this diff. Show more