Merge branch 'master' into wrap-pod-logs

This commit is contained in:
Alex Andreev committed 2022-11-28 10:11:28 +03:00
commit 31c7ae118d
1328 files changed
+73244 -43415

No files matched your search

-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,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 { 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: () => ({
run: () => {
apiBase?.onError.addListener(onApiError);
},
}),
injectionToken: beforeFrameStartsInjectionToken,
causesSideEffects: true,
});
export default setupOnApiErrorListenersInjectable;
@@ -5,7 +5,7 @@
import { getInjectable } from "@ogre-tools/injectable";
import appPathsStateInjectable from "../../common/app-paths/app-paths-state.injectable";
import { beforeFrameStartsInjectionToken } from "../before-frame-starts/before-frame-starts-injection-token";
import appPathsChannelInjectable from "../../common/app-paths/app-paths-channel.injectable";
import { appPathsChannel } from "../../common/app-paths/app-paths-channel";
import { requestFromChannelInjectionToken } from "../../common/utils/channel/request-from-channel-injection-token";
const setupAppPathsInjectable = getInjectable({
@@ -13,14 +13,12 @@ const setupAppPathsInjectable = getInjectable({
instantiate: (di) => {
const requestFromChannel = di.inject(requestFromChannelInjectionToken);
const appPathsChannel = di.inject(appPathsChannelInjectable);
const appPathsState = di.inject(appPathsStateInjectable);
return {
id: "setup-app-paths",
run: async () => {
const appPaths = await requestFromChannel(
appPathsChannel,
);
const appPaths = await requestFromChannel(appPathsChannel);
appPathsState.set(appPaths);
},
@@ -1,39 +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 { computed } from "mobx";
import { rootFrameChildComponentInjectionToken } from "../../frames/root-frame/root-frame-child-component-injection-token";
import { ForceUpdateModal } from "./force-update-modal";
import timeSinceUpdateWasDownloadedInjectable from "./time-since-update-was-downloaded.injectable";
import updateDownloadedDateTimeInjectable from "../../../common/application-update/update-downloaded-date-time/update-downloaded-date-time.injectable";
import timeAfterUpdateMustBeInstalledInjectable from "./time-after-update-must-be-installed.injectable";
const forceUpdateModalRootFrameComponentInjectable = getInjectable({
id: "force-update-modal-root-frame-component",
instantiate: (di) => {
const timeSinceUpdateWasDownloaded = di.inject(timeSinceUpdateWasDownloadedInjectable);
const updateDownloadedDateTime = di.inject(updateDownloadedDateTimeInjectable);
const timeWhenUpdateMustBeInstalled = di.inject(timeAfterUpdateMustBeInstalledInjectable);
return {
id: "force-update-modal",
Component: ForceUpdateModal,
shouldRender: computed(
() =>
!!updateDownloadedDateTime.value.get() &&
timeSinceUpdateWasDownloaded.get() >= timeWhenUpdateMustBeInstalled,
),
};
},
injectionToken: rootFrameChildComponentInjectionToken,
// Note: Globally overridden because it observes the current time which can cause long running tests
causesSideEffects: true,
});
export default forceUpdateModalRootFrameComponentInjectable;
@@ -1,25 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
$baseline: 8px;
.ForceUpdateModal {
background-color: white;
border-radius: 1 * $baseline;
padding: 3 * $baseline;
width: 50 * $baseline;
.header {
margin-bottom: 2 * $baseline;
}
.content {
margin-bottom: 2 * $baseline;
}
.footer {
display: flex;
justify-content: center;
}
}
@@ -1,69 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { withInjectables } from "@ogre-tools/injectable-react";
import React from "react";
import restartAndInstallUpdateInjectable from "../../components/update-button/restart-and-install-update.injectable";
import { Countdown } from "../../components/countdown/countdown";
import type { IComputedValue } from "mobx";
import { observer } from "mobx-react";
import installUpdateCountdownInjectable from "./install-update-countdown.injectable";
import { Dialog } from "../../components/dialog";
import { Button } from "../../components/button";
import styles from "./force-update-modal.module.scss";
interface Dependencies {
restartAndInstallUpdate: () => void;
secondsTill: IComputedValue<number>;
}
const NonInjectedForceUpdateModal = observer(
({ restartAndInstallUpdate, secondsTill }: Dependencies) => (
<Dialog isOpen={true} pinned>
<div
data-testid="must-update-immediately"
className={styles.ForceUpdateModal}
>
<div className={styles.header}>
<h2>Please update</h2>
</div>
<div className={styles.content}>
<p>
An update to Lens Desktop is required to continue using the application.
</p>
</div>
<div className={styles.footer}>
<Button
primary
data-testid="update-now-from-must-update-immediately-modal"
onClick={restartAndInstallUpdate}
label="Update"
>
{" "}
(
<Countdown
secondsTill={secondsTill}
data-testid="countdown-to-automatic-update"
/>
)
</Button>
</div>
</div>
</Dialog>
),
);
export const ForceUpdateModal = withInjectables<Dependencies>(
NonInjectedForceUpdateModal,
{
getProps: (di, props) => ({
restartAndInstallUpdate: di.inject(restartAndInstallUpdateInjectable),
secondsTill: di.inject(installUpdateCountdownInjectable),
...props,
}),
},
);
@@ -1,27 +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 countdownStateInjectable from "../../components/countdown/countdown-state.injectable";
import secondsAfterInstallStartsInjectable from "./seconds-after-install-starts.injectable";
import restartAndInstallUpdateInjectable from "../../components/update-button/restart-and-install-update.injectable";
const installUpdateCountdownInjectable = getInjectable({
id: "install-update-countdown",
instantiate: (di) => {
const secondsAfterInstallStarts = di.inject(secondsAfterInstallStartsInjectable);
const restartAndInstallUpdate = di.inject(restartAndInstallUpdateInjectable);
return di.inject(countdownStateInjectable, {
startFrom: secondsAfterInstallStarts,
onZero: () => {
restartAndInstallUpdate();
},
});
},
});
export default installUpdateCountdownInjectable;
@@ -1,14 +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";
const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000;
const timeAfterUpdateMustBeInstalledInjectable = getInjectable({
id: "time-after-update-must-be-installed",
instantiate: () => THIRTY_DAYS,
});
export default timeAfterUpdateMustBeInstalledInjectable;
@@ -1,30 +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 assert from "assert";
import { computed } from "mobx";
import moment from "moment";
import updateDownloadedDateTimeInjectable from "../../../common/application-update/update-downloaded-date-time/update-downloaded-date-time.injectable";
import { reactiveNow } from "../../../common/utils/reactive-now/reactive-now";
const timeSinceUpdateWasDownloadedInjectable = getInjectable({
id: "time-since-update-was-downloaded",
instantiate: (di) => {
const updateDownloadedDateTime = di.inject(updateDownloadedDateTimeInjectable);
return computed(() => {
const currentTimestamp = reactiveNow();
const downloadedAt = updateDownloadedDateTime.value.get();
assert(downloadedAt);
return currentTimestamp - (moment(downloadedAt).unix() * 1000);
});
},
});
export default timeSinceUpdateWasDownloadedInjectable;
+4 -6
View File
@@ -20,7 +20,6 @@ import configurePackages from "../common/configure-packages";
import * as initializers from "./initializers";
import logger from "../common/logger";
import { WeblinkStore } from "../common/weblink-store";
import { initializeSentryReporting } from "../common/sentry";
import { registerCustomThemes } from "./components/monaco-editor";
import { getDi } from "./getDi";
import { DiContextProvider } from "@ogre-tools/injectable-react";
@@ -29,7 +28,6 @@ import extensionLoaderInjectable from "../extensions/extension-loader/extension-
import extensionDiscoveryInjectable from "../extensions/extension-discovery/extension-discovery.injectable";
import extensionInstallationStateStoreInjectable from "../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import clusterStoreInjectable from "../common/cluster-store/cluster-store.injectable";
import userStoreInjectable from "../common/user-store/user-store.injectable";
import initRootFrameInjectable from "./frames/root-frame/init-root-frame/init-root-frame.injectable";
import initClusterFrameInjectable from "./frames/cluster-frame/init-cluster-frame/init-cluster-frame.injectable";
import commandOverlayInjectable from "./components/command-palette/command-overlay.injectable";
@@ -46,6 +44,7 @@ import kubernetesClusterCategoryInjectable from "../common/catalog/categories/ku
import autoRegistrationInjectable from "../common/k8s-api/api-manager/auto-registration.injectable";
import assert from "assert";
import startFrameInjectable from "./start-frame/start-frame.injectable";
import initializeSentryReportingWithInjectable from "../common/error-reporting/initialize-sentry-reporting.injectable";
configurePackages(); // global packages
registerCustomThemes(); // monaco editor themes
@@ -62,8 +61,10 @@ async function attachChromeDebugger() {
}
export async function bootstrap(di: DiContainer) {
const initializeSentryReportingWith = di.inject(initializeSentryReportingWithInjectable);
if (process.isMainFrame) {
initializeSentryReporting(init);
initializeSentryReportingWith(init);
}
const startFrame = di.inject(startFrameInjectable);
@@ -90,9 +91,6 @@ export async function bootstrap(di: DiContainer) {
*/
di.inject(autoRegistrationInjectable);
// TODO: Remove temporal dependencies to make timing of initialization not important
di.inject(userStoreInjectable);
await attachChromeDebugger();
rootElem.classList.toggle("is-mac", isMac);
@@ -10,10 +10,8 @@ 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";
import { loadConfigFromString, splitConfig } from "../../../common/kube-helpers";
import { docsUrl } from "../../../common/vars";
import { isDefined, iter } from "../../utils";
@@ -25,6 +23,10 @@ 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 { EmitAppEvent } from "../../../common/app-event-bus/emit-event.injectable";
import emitAppEventInjectable from "../../../common/app-event-bus/emit-event.injectable";
import type { GetDirnameOfPath } from "../../../common/path/get-dirname.injectable";
import getDirnameOfPathInjectable from "../../../common/path/get-dirname.injectable";
interface Option {
config: KubeConfig;
@@ -34,6 +36,8 @@ interface Option {
interface Dependencies {
getCustomKubeConfigDirectory: (directoryName: string) => string;
navigateToCatalog: NavigateToCatalog;
getDirnameOfPath: GetDirnameOfPath;
emitAppEvent: EmitAppEvent;
}
function getContexts(config: KubeConfig): Map<string, Option> {
@@ -53,13 +57,13 @@ class NonInjectedAddCluster extends React.Component<Dependencies> {
@observable isWaiting = false;
@observable errors: string[] = [];
constructor(dependencies: Dependencies) {
super(dependencies);
constructor(props: Dependencies) {
super(props);
makeObservable(this);
}
componentDidMount() {
appEventBus.emit({ name: "cluster-add", action: "start" });
this.props.emitAppEvent({ name: "cluster-add", action: "start" });
}
@computed get allErrors(): string[] {
@@ -85,12 +89,12 @@ class NonInjectedAddCluster extends React.Component<Dependencies> {
addClusters = action(async () => {
this.isWaiting = true;
appEventBus.emit({ name: "cluster-add", action: "click" });
this.props.emitAppEvent({ name: "cluster-add", action: "click" });
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)`);
@@ -112,7 +116,7 @@ class NonInjectedAddCluster extends React.Component<Dependencies> {
<code>~/.kube/config</code>
{" file. "}
<a
href={`${docsUrl}/catalog/add-cluster/`}
href={`${docsUrl}/getting-started/add-cluster/`}
rel="noreferrer"
target="_blank"
>
@@ -155,10 +159,9 @@ 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),
emitAppEvent: di.inject(emitAppEventInjectable),
}),
});
@@ -3,11 +3,13 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { CatalogCategorySpec } from "../../../../common/catalog";
import { CatalogCategory } from "../../../../common/catalog";
import { CatalogAddButton } from "../catalog-add-button";
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import { type DiRender, renderFor } from "../../test-utils/renderFor";
class TestCatalogCategory extends CatalogCategory {
public readonly apiVersion = "catalog.k8slens.dev/v1alpha1";
@@ -26,6 +28,14 @@ class TestCatalogCategory extends CatalogCategory {
}
describe("CatalogAddButton", () => {
let render: DiRender;
beforeEach(() => {
const di = getDiForUnitTesting({ doGeneralOverrides: true });
render = renderFor(di);
});
it("opens Add menu", async () => {
const category = new TestCatalogCategory();
@@ -4,16 +4,15 @@
*/
import type { DiContainer } from "@ogre-tools/injectable";
import { computed } from "mobx";
import type { CatalogCategorySpec } from "../../../../common/catalog";
import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable";
import { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
import { CatalogCategory } from "../../../api/catalog-entity";
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import type { AdditionalCategoryColumnRegistration, CategoryColumnRegistration } from "../custom-category-columns";
import type { CategoryColumns, GetCategoryColumnsParams } from "../columns/get.injectable";
import getCategoryColumnsInjectable from "../columns/get.injectable";
import hotbarStoreInjectable from "../../../../common/hotbars/store.injectable";
import extensionInjectable from "../../../../extensions/extension-loader/extension/extension.injectable";
class TestCategory extends CatalogCategory {
apiVersion = "catalog.k8slens.dev/v1alpha1";
@@ -41,21 +40,16 @@ class TestCategory extends CatalogCategory {
describe("Custom Category Columns", () => {
let di: DiContainer;
let getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns;
beforeEach(() => {
di = getDiForUnitTesting({ doGeneralOverrides: true });
di.override(hotbarStoreInjectable, () => ({}));
getCategoryColumns = di.inject(getCategoryColumnsInjectable);
});
describe("without extensions", () => {
let getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns;
beforeEach(() => {
di.override(rendererExtensionsInjectable, () => computed(() => [] as LensRendererExtension[]));
getCategoryColumns = di.inject(getCategoryColumnsInjectable);
});
it("should contain a kind column if activeCategory is falsy", () => {
expect(getCategoryColumns({ activeCategory: null }).renderTableHeader.find(elem => elem?.title === "Kind")).toBeTruthy();
});
@@ -88,13 +82,9 @@ describe("Custom Category Columns", () => {
});
describe("with extensions", () => {
let getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns;
beforeEach(() => {
di.override(rendererExtensionsInjectable, () => computed(() => [
{
name: "test-extension",
additionalCategoryColumns: [
const ext = di.inject(extensionInjectable, new (class extends LensRendererExtension {
additionalCategoryColumns = [
{
group: "foo.bar.bat",
id: "high",
@@ -113,10 +103,24 @@ describe("Custom Category Columns", () => {
title: "High2",
},
} as AdditionalCategoryColumnRegistration,
],
} as LensRendererExtension,
]));
getCategoryColumns = di.inject(getCategoryColumnsInjectable);
];
})({
absolutePath: "/some-absolute-path",
id: "some-id",
isBundled: false,
isCompatible: true,
isEnabled: true,
manifest: {
engines: {
lens: "",
},
name: "some-extension-name",
version: "1.0.0",
},
manifestPath: "/some-manifest-path",
}));
ext.register();
});
it("should include columns from extensions that match", () => {
@@ -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),
}),
});
@@ -57,7 +57,7 @@ describe("<Catalog />", () => {
let di: DiContainer;
let catalogEntityStore: CatalogEntityStore;
let catalogEntityRegistry: CatalogEntityRegistry;
let emitEvent: (event: AppEvent) => void;
let appEventListener: jest.MockedFunction<(event: AppEvent) => void>;
let onRun: jest.MockedFunction<(context: CatalogEntityActionContext) => void | Promise<void>>;
let catalogEntityItem: MockCatalogEntity;
let render: DiRender;
@@ -78,11 +78,8 @@ describe("<Catalog />", () => {
catalogEntityItem = createMockCatalogEntity(onRun);
catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable);
emitEvent = jest.fn();
di.override(appEventBusInjectable, () => ({
emit: emitEvent,
}));
appEventListener = jest.fn();
di.inject(appEventBusInjectable).addListener(appEventListener);
catalogEntityStore = di.inject(catalogEntityStoreInjectable);
Object.assign(catalogEntityStore, {
@@ -204,24 +201,20 @@ describe("<Catalog />", () => {
});
it("emits catalog open AppEvent", () => {
render(
<Catalog />,
);
render(<Catalog />);
expect(emitEvent).toHaveBeenCalledWith( {
expect(appEventListener).toHaveBeenCalledWith( {
action: "open",
name: "catalog",
});
});
it("emits catalog change AppEvent when changing the category", () => {
render(
<Catalog />,
);
render(<Catalog />);
userEvent.click(screen.getByText("Web Links"));
expect(emitEvent).toHaveBeenLastCalledWith({
expect(appEventListener).toHaveBeenCalledWith({
action: "change-category",
name: "catalog",
params: {
+4 -4
View File
@@ -37,8 +37,6 @@ import type { NavigateToCatalog } from "../../../common/front-end-routing/routes
import navigateToCatalogInjectable from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";
import catalogRouteParametersInjectable from "./catalog-route-parameters.injectable";
import { browseCatalogTab } from "./catalog-browse-tab";
import type { AppEvent } from "../../../common/app-event-bus/event-bus";
import appEventBusInjectable from "../../../common/app-event-bus/app-event-bus.injectable";
import hotbarStoreInjectable from "../../../common/hotbars/store.injectable";
import type { HotbarStore } from "../../../common/hotbars/store";
import type { VisitEntityContextMenu } from "../../../common/catalog/visit-entity-context-menu.injectable";
@@ -48,13 +46,15 @@ import type { Navigate } from "../../navigation/navigate.injectable";
import navigateInjectable from "../../navigation/navigate.injectable";
import type { NormalizeCatalogEntityContextMenu } from "../../catalog/normalize-menu-item.injectable";
import normalizeCatalogEntityContextMenuInjectable from "../../catalog/normalize-menu-item.injectable";
import type { EmitAppEvent } from "../../../common/app-event-bus/emit-event.injectable";
import emitAppEventInjectable from "../../../common/app-event-bus/emit-event.injectable";
interface Dependencies {
catalogPreviousActiveTabStorage: StorageLayer<string | null>;
catalogEntityStore: CatalogEntityStore;
getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns;
customCategoryViews: IComputedValue<Map<string, Map<string, RegisteredCustomCategoryViewDecl>>>;
emitEvent: (event: AppEvent) => void;
emitEvent: EmitAppEvent;
routeParameters: {
group: IComputedValue<string>;
kind: IComputedValue<string>;
@@ -356,7 +356,7 @@ export const Catalog = withInjectables<Dependencies>(NonInjectedCatalog, {
customCategoryViews: di.inject(customCategoryViewsInjectable),
routeParameters: di.inject(catalogRouteParametersInjectable),
navigateToCatalog: di.inject(navigateToCatalogInjectable),
emitEvent: di.inject(appEventBusInjectable).emit,
emitEvent: di.inject(emitAppEventInjectable),
hotbarStore: di.inject(hotbarStoreInjectable),
catalogCategoryRegistry: di.inject(catalogCategoryRegistryInjectable),
visitEntityContextMenu: di.inject(visitEntityContextMenuInjectable),
@@ -0,0 +1,17 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectionToken } from "@ogre-tools/injectable";
import type { RegisteredAdditionalCategoryColumn } from "../custom-category-columns";
export interface CustomCatalogCategoryRegistration {
kind: string;
group: string;
registration: RegisteredAdditionalCategoryColumn;
}
export const customCatalogCategoryColumnInjectionToken = getInjectionToken<CustomCatalogCategoryRegistration>({
id: "custom-catalog-category-column-token",
});
@@ -0,0 +1,58 @@
/**
* 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 { ExtensionRegistrator } from "../../../../extensions/extension-loader/extension-registrator-injection-token";
import { extensionRegistratorInjectionToken } from "../../../../extensions/extension-loader/extension-registrator-injection-token";
import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
import type { AdditionalCategoryColumnRegistration } from "../custom-category-columns";
import { customCatalogCategoryColumnInjectionToken } from "./custom-token";
const customCategoryColumnsRegistratorInjectable = getInjectable({
id: "custom-category-columns-registrator",
instantiate: (): ExtensionRegistrator => {
return (ext) => {
const extension = ext as LensRendererExtension;
return extension.additionalCategoryColumns.map(getInjectableForColumnRegistrationFor(extension));
};
},
injectionToken: extensionRegistratorInjectionToken,
});
export default customCategoryColumnsRegistratorInjectable;
const getInjectableForColumnRegistrationFor = (extension: LensRendererExtension) => ({
group,
id,
kind,
renderCell,
titleProps,
priority = 50,
searchFilter,
sortCallback,
}: AdditionalCategoryColumnRegistration) => {
return getInjectable({
id: `${extension.manifest.name}:${group}/${kind}:${id}`,
instantiate: () => ({
group,
kind,
registration: {
renderCell,
priority,
id,
titleProps: {
id,
...titleProps,
sortBy: sortCallback
? id
: undefined,
},
searchFilter,
sortCallback,
},
}),
injectionToken: customCatalogCategoryColumnInjectionToken,
});
};
@@ -47,7 +47,10 @@ const getCategoryColumnsInjectable = getInjectable({
}
tableRowRenderers.push(registration.renderCell);
renderTableHeader.push(registration.titleProps);
renderTableHeader.push({
id: registration.id,
...registration.titleProps,
});
}
return {
@@ -0,0 +1,35 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import React from "react";
import type { KubernetesCluster } from "../../../../common/catalog-entities";
import { customCatalogCategoryColumnInjectionToken } from "./custom-token";
const kubernetesApiVersionColumnInjectable = getInjectable({
id: "kubernetes-api-version-column",
instantiate: () => ({
group: "entity.k8slens.dev",
kind: "KubernetesCluster",
registration: {
id: "version",
priority: 30,
renderCell: entity => {
const k8sVersion = (entity as KubernetesCluster).metadata.kubeVersion;
return (
<span key="version">
{k8sVersion === "unknown" ? "" : k8sVersion}
</span>
);
},
titleProps: {
title: "Version",
},
},
}),
injectionToken: customCatalogCategoryColumnInjectionToken,
});
export default kubernetesApiVersionColumnInjectable;
@@ -0,0 +1,36 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import React from "react";
import type { KubernetesCluster } from "../../../../common/catalog-entities";
import { customCatalogCategoryColumnInjectionToken } from "./custom-token";
const kubernetesDistributionColumnInjectable = getInjectable({
id: "kubernetes-distribution-column",
instantiate: () => ({
group: "entity.k8slens.dev",
kind: "KubernetesCluster",
registration: {
id: "distro",
priority: 30,
renderCell: entity => {
const k8sDistro = (entity as KubernetesCluster).metadata.distro;
return (
<span key="distro">
{k8sDistro === "unknown" ? "" : k8sDistro}
</span>
);
},
titleProps: {
title: "Distro",
},
},
}),
injectionToken: customCatalogCategoryColumnInjectionToken,
});
export default kubernetesDistributionColumnInjectable;
@@ -3,54 +3,31 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { IComputedValue } from "mobx";
import { computedInjectManyInjectable } from "@ogre-tools/injectable-extension-for-mobx";
import { computed } from "mobx";
import type { LensRendererExtension } from "../../../extensions/lens-renderer-extension";
import rendererExtensionsInjectable from "../../../extensions/renderer-extensions.injectable";
import { getOrInsert, getOrInsertMap } from "../../utils";
import { customCatalogCategoryColumnInjectionToken } from "./columns/custom-token";
import type { RegisteredAdditionalCategoryColumn } from "./custom-category-columns";
interface Dependencies {
extensions: IComputedValue<LensRendererExtension[]>;
}
function getAdditionCategoryColumns({ extensions }: Dependencies): IComputedValue<Map<string, Map<string, RegisteredAdditionalCategoryColumn[]>>> {
return computed(() => {
const res = new Map<string, Map<string, RegisteredAdditionalCategoryColumn[]>>();
for (const ext of extensions.get()) {
for (const { renderCell, titleProps, priority = 50, searchFilter, sortCallback, ...registration } of ext.additionalCategoryColumns) {
const byGroup = getOrInsertMap(res, registration.group);
const byKind = getOrInsert(byGroup, registration.kind, []);
const id = `${ext.name}:${registration.id}`;
byKind.push({
renderCell,
priority,
id,
titleProps: {
id,
...titleProps,
sortBy: sortCallback
? id
: undefined,
},
searchFilter,
sortCallback,
});
}
}
return res;
});
}
const categoryColumnsInjectable = getInjectable({
id: "category-columns",
instantiate: (di) => {
const computedInjectMany = di.inject(computedInjectManyInjectable);
const columnRegistrations = computedInjectMany(customCatalogCategoryColumnInjectionToken);
instantiate: (di) => getAdditionCategoryColumns({
extensions: di.inject(rendererExtensionsInjectable),
}),
return computed(() => {
const res = new Map<string, Map<string, RegisteredAdditionalCategoryColumn[]>>();
for (const { group, kind, registration } of columnRegistrations.get()) {
const byGroup = getOrInsertMap(res, group);
const byKind = getOrInsert(byGroup, kind, []);
byKind.push(registration);
}
return res;
});
},
});
export default categoryColumnsInjectable;
@@ -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 (
@@ -3,4 +3,5 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export { apiBase, apiKube } from "../../common/k8s-api";
export * from "./leases";
export * from "./lease-details";
@@ -3,4 +3,4 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export * from "./preferences";
.LeaseDetails {}
@@ -0,0 +1,51 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./lease-details.scss";
import React from "react";
import { observer } from "mobx-react";
import { DrawerItem } from "../drawer";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import { KubeObjectMeta } from "../kube-object-meta";
import type { Lease } from "../../../common/k8s-api/endpoints";
export interface LeaseDetailsProps extends KubeObjectDetailsProps<Lease> {
}
@observer
export class LeaseDetails extends React.Component<LeaseDetailsProps> {
render() {
const { object: lease } = this.props;
return (
<div className="LeaseDetails">
<KubeObjectMeta object={lease} />
<DrawerItem name="Holder Identity">
{lease.getHolderIdentity()}
</DrawerItem>
<DrawerItem name="Lease Duration Seconds">
{lease.getLeaseDurationSeconds()}
</DrawerItem>
<DrawerItem name="Lease Transitions" hidden={lease.getLeaseTransitions() === undefined}>
{lease.getLeaseTransitions()}
</DrawerItem>
<DrawerItem name="Acquire Time" hidden={lease.getAcquireTime() === ""}>
{lease.getAcquireTime()}
</DrawerItem>
<DrawerItem name="Renew Time">
{lease.getRenewTime()}
</DrawerItem>
</div >
);
}
}
@@ -0,0 +1,21 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { Leases } from "./leases";
import leasesRouteInjectable from "../../../common/front-end-routing/routes/cluster/config/leases/leases-route.injectable";
import { routeSpecificComponentInjectionToken } from "../../routes/route-specific-component-injection-token";
const leasesRouteComponentInjectable = getInjectable({
id: "leases-route-component",
instantiate: (di) => ({
route: di.inject(leasesRouteInjectable),
Component: Leases,
}),
injectionToken: routeSpecificComponentInjectionToken,
});
export default leasesRouteComponentInjectable;
@@ -0,0 +1,38 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { computed } from "mobx";
import leasesRouteInjectable from "../../../common/front-end-routing/routes/cluster/config/leases/leases-route.injectable";
import { configSidebarItemId } from "../+config/config-sidebar-items.injectable";
import { sidebarItemsInjectionToken } from "../layout/sidebar-items.injectable";
import routeIsActiveInjectable from "../../routes/route-is-active.injectable";
import navigateToLeasesInjectable from "../../../common/front-end-routing/routes/cluster/config/leases/navigate-to-leases.injectable";
const leasesSidebarItemsInjectable = getInjectable({
id: "leases-sidebar-items",
instantiate: (di) => {
const route = di.inject(leasesRouteInjectable);
const navigateToLeases = di.inject(navigateToLeasesInjectable);
const routeIsActive = di.inject(routeIsActiveInjectable, route);
return computed(() => [
{
id: "leases",
parentId: configSidebarItemId,
title: "Leases",
onClick: navigateToLeases,
isActive: routeIsActive,
isVisible: route.isEnabled,
orderNumber: 80,
},
]);
},
injectionToken: sidebarItemsInjectionToken,
});
export default leasesSidebarItemsInjectable;
@@ -0,0 +1,24 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.Leases {
.TableCell {
&.name {
flex: 2;
}
&.warning {
@include table-cell-warning;
}
&.keys {
flex: 2.5;
}
&.age {
flex: .5;
}
}
}
@@ -0,0 +1,87 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./leases.scss";
import * as React from "react";
import { observer } from "mobx-react";
import type { Lease } from "../../../common/k8s-api/endpoints/lease.api";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import { KubeObjectListLayout } from "../kube-object-list-layout";
import { SiblingsInTabLayout } from "../layout/siblings-in-tab-layout";
import { KubeObjectAge } from "../kube-object/age";
import { autoBind } from "../../../common/utils";
import { withInjectables } from "@ogre-tools/injectable-react";
import leaseStoreInjectable from "./store.injectable";
import type { LeaseStore } from "./store";
enum columnId {
name = "name",
namespace = "namespace",
holder = "holder",
age = "age",
}
export interface LeaseProps extends KubeObjectDetailsProps<Lease> {
}
interface Dependencies {
leaseStore: LeaseStore;
}
@observer
class NonInjectedLease extends React.Component<LeaseProps & Dependencies> {
constructor(props: LeaseProps & Dependencies) {
super(props);
autoBind(this);
}
render() {
const { leaseStore } = this.props;
return (
<SiblingsInTabLayout>
<KubeObjectListLayout
isConfigurable
tableId="configuration_leases"
className="Leases"
store={leaseStore}
sortingCallbacks={{
[columnId.name]: lease => lease.getName(),
[columnId.namespace]: lease => lease.getNs(),
[columnId.holder]: lease => lease.getHolderIdentity(),
[columnId.age]: lease => -lease.getCreationTimestamp(),
}}
searchFilters={[
lease => lease.getSearchFields(),
]}
renderHeaderTitle="Leases"
renderTableHeader={[
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ className: "warning", showWithColumn: columnId.name },
{ title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
{ title: "Holder", className: "holder", sortBy: columnId.holder, id: columnId.holder },
{ title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
]}
renderTableContents={lease => [
lease.getName(),
<KubeObjectStatusIcon key="icon" object={lease} />,
lease.getNs(),
lease.getHolderIdentity(),
<KubeObjectAge key="age" object={lease} />,
]}
/>
</SiblingsInTabLayout>
);
}
}
export const Leases = withInjectables<Dependencies, LeaseProps>(NonInjectedLease, {
getProps: (di, props) => ({
...props,
leaseStore: di.inject(leaseStoreInjectable),
}),
});
@@ -0,0 +1,24 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import assert from "assert";
import { kubeObjectStoreInjectionToken } from "../../../common/k8s-api/api-manager/manager.injectable";
import leaseApiInjectable from "../../../common/k8s-api/endpoints/lease.api.injectable";
import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable";
import { LeaseStore } from "./store";
const leaseStoreInjectable = getInjectable({
id: "lease-store",
instantiate: (di) => {
assert(di.inject(storesAndApisCanBeCreatedInjectable), "leaseStore is only available in certain environments");
const api = di.inject(leaseApiInjectable);
return new LeaseStore(api);
},
injectionToken: kubeObjectStoreInjectionToken,
});
export default leaseStoreInjectable;
@@ -0,0 +1,10 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import type { Lease, LeaseApi } from "../../../common/k8s-api/endpoints/lease.api";
export class LeaseStore extends KubeObjectStore<Lease, LeaseApi> {
}
@@ -27,7 +27,7 @@ const priorityClassesSidebarItemsInjectable = getInjectable({
onClick: navigateToPriorityClasses,
isActive: routeIsActive,
isVisible: route.isEnabled,
orderNumber: 60,
orderNumber: 70,
},
]);
},
@@ -7,7 +7,7 @@ import { PriorityClasses } from "./priority-classes";
import priorityClassesRouteInjectable from "../../../common/front-end-routing/routes/cluster/config/priority-classes/priority-classes-route.injectable";
import { routeSpecificComponentInjectionToken } from "../../routes/route-specific-component-injection-token";
const podDisruptionBudgetsRouteComponentInjectable = getInjectable({
const priorityClassesRouteComponentInjectable = getInjectable({
id: "priority-classes-route-component",
instantiate: (di) => ({
@@ -18,4 +18,4 @@ const podDisruptionBudgetsRouteComponentInjectable = getInjectable({
injectionToken: routeSpecificComponentInjectionToken,
});
export default podDisruptionBudgetsRouteComponentInjectable;
export default priorityClassesRouteComponentInjectable;
@@ -3,8 +3,5 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.Preferences {
.loading-spinner {
margin: auto;
}
}
export * from "./runtime-classes";
export * from "./runtime-classes-details";
@@ -0,0 +1,28 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.RuntimeClassDetailsTolerations {
grid-template-columns: auto;
.RuntimeClassTolerations {
margin-top: var(--margin);
}
// Expanding value cell to cover 2 columns (whole Drawer width)
>.name {
grid-row-start: 1;
grid-column-start: 1;
}
>.value {
grid-row-start: 1;
grid-column-start: 1;
}
.DrawerParamToggler>.params {
margin-left: var(--drawer-item-title-width);
}
}
@@ -0,0 +1,32 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./runtime-classes-details-tolerations.scss";
import React from "react";
import { DrawerParamToggler, DrawerItem } from "../drawer";
import type { Toleration, KubeObject } from "../../../common/k8s-api/kube-object";
import { RuntimeClassTolerations } from "./runtime-classes-tolerations";
export interface KubeObjectWithTolerations extends KubeObject {
getTolerations(): Toleration[];
}
export interface RuntimeClassDetailsTolerationsProps {
runtimeClass: KubeObjectWithTolerations;
}
export function RuntimeClassDetailsTolerations({ runtimeClass: runtimeClass }: RuntimeClassDetailsTolerationsProps) {
const tolerations = runtimeClass.getTolerations();
if (!tolerations.length) return null;
return (
<DrawerItem name="Tolerations" className="RuntimeClassDetailsTolerations">
<DrawerParamToggler label={tolerations.length}>
<RuntimeClassTolerations tolerations={tolerations} />
</DrawerParamToggler>
</DrawerItem>
);
}
@@ -3,4 +3,4 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export * from "./update-button";
.RuntimeClassesDetails {}
@@ -0,0 +1,48 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./runtime-classes.scss";
import React from "react";
import { observer } from "mobx-react";
import { DrawerItem } from "../drawer";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import type { RuntimeClass } from "../../../common/k8s-api/endpoints";
import { KubeObjectMeta } from "../kube-object-meta";
import { Badge } from "../badge";
import { RuntimeClassDetailsTolerations } from "./runtime-classes-details-tolerations";
export interface RuntimeClassesDetailsProps extends KubeObjectDetailsProps<RuntimeClass> {
}
@observer
export class RuntimeClassesDetails extends React.Component<RuntimeClassesDetailsProps> {
render() {
const { object: rc } = this.props;
const nodeSelector = rc.getNodeSelectors();
return (
<div className="RuntimeClassesDetails">
<KubeObjectMeta object={rc} />
<DrawerItem name="Handler">
{rc.getHandler()}
</DrawerItem>
<DrawerItem name="Pod Fixed" hidden={rc.getPodFixed() === ""}>
{rc.getPodFixed()}
</DrawerItem>
<DrawerItem name="Node Selector" hidden={nodeSelector.length === 0}>
{nodeSelector.map(label => <Badge key={label} label={label} />)}
</DrawerItem>
<RuntimeClassDetailsTolerations runtimeClass={rc} />
</div>
);
}
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { computed } from "mobx";
import runtimeClassesRouteInjectable from "../../../common/front-end-routing/routes/cluster/config/runtime-classes/runtime-classes-route.injectable";
import { configSidebarItemId } from "../+config/config-sidebar-items.injectable";
import { sidebarItemsInjectionToken } from "../layout/sidebar-items.injectable";
import routeIsActiveInjectable from "../../routes/route-is-active.injectable";
import navigateToRuntimeClassesInjectable from "../../../common/front-end-routing/routes/cluster/config/runtime-classes/navigate-to-runtime-classes.injectable";
const runtimeClassesSidebarItemsInjectable = getInjectable({
id: "runtime-classes-sidebar-items",
instantiate: (di) => {
const route = di.inject(runtimeClassesRouteInjectable);
const navigateToRuntimeClasses = di.inject(navigateToRuntimeClassesInjectable);
const routeIsActive = di.inject(routeIsActiveInjectable, route);
return computed(() => [
{
id: "runtime-classes",
parentId: configSidebarItemId,
title: "Runtime Classes",
onClick: navigateToRuntimeClasses,
isActive: routeIsActive,
isVisible: route.isEnabled,
orderNumber: 70,
},
]);
},
injectionToken: sidebarItemsInjectionToken,
});
export default runtimeClassesSidebarItemsInjectable;
@@ -3,19 +3,19 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { RuntimeClasses } from "./runtime-classes";
import runtimeClassesRouteInjectable from "../../../common/front-end-routing/routes/cluster/config/runtime-classes/runtime-classes-route.injectable";
import { routeSpecificComponentInjectionToken } from "../../routes/route-specific-component-injection-token";
import terminalPreferencesRouteInjectable from "../../../common/front-end-routing/routes/preferences/terminal/terminal-preferences-route.injectable";
import { Terminal } from "./terminal";
const terminalPreferencesRouteComponentInjectable = getInjectable({
id: "terminal-preferences-route-component",
const runtimeClassesRouteComponentInjectable = getInjectable({
id: "runtime-classes-route-component",
instantiate: (di) => ({
route: di.inject(terminalPreferencesRouteInjectable),
Component: Terminal,
route: di.inject(runtimeClassesRouteInjectable),
Component: RuntimeClasses,
}),
injectionToken: routeSpecificComponentInjectionToken,
});
export default terminalPreferencesRouteComponentInjectable;
export default runtimeClassesRouteComponentInjectable;
@@ -0,0 +1,19 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.RuntimeClassTolerations {
.TableHead {
background-color: var(--drawerSubtitleBackground);
}
.TableCell {
white-space: normal;
word-break: normal;
&.key {
flex-grow: 3;
}
}
}
@@ -0,0 +1,69 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./runtime-classes-tolerations.scss";
import React from "react";
import uniqueId from "lodash/uniqueId";
import type { Toleration } from "../../../common/k8s-api/kube-object";
import { Table, TableCell, TableHead, TableRow } from "../table";
export interface RuntimeClassTolerationsProps {
tolerations: Toleration[];
}
enum sortBy {
Key = "key",
Operator = "operator",
Effect = "effect",
Seconds = "seconds",
Value = "value",
}
const getTableRow = (toleration: Toleration) => {
const { key, operator, effect, tolerationSeconds, value } = toleration;
return (
<TableRow
key={uniqueId("toleration-")}
sortItem={toleration}
nowrap
>
<TableCell className="key">{key}</TableCell>
<TableCell className="operator">{operator}</TableCell>
<TableCell className="value">{value}</TableCell>
<TableCell className="effect">{effect}</TableCell>
<TableCell className="seconds">{tolerationSeconds}</TableCell>
</TableRow>
);
};
export function RuntimeClassTolerations({ tolerations }: RuntimeClassTolerationsProps) {
return (
<Table
tableId="workloads_pod_tolerations"
selectable
items={tolerations}
scrollable={false}
sortable={{
[sortBy.Key]: toleration => toleration.key,
[sortBy.Operator]: toleration => toleration.operator,
[sortBy.Effect]: toleration => toleration.effect,
[sortBy.Seconds]: toleration => toleration.tolerationSeconds,
}}
sortSyncWithUrl={false}
className="RuntimeClassTolerations"
renderRow={getTableRow}
>
<TableHead sticky={false}>
<TableCell className="key" sortBy={sortBy.Key}>Key</TableCell>
<TableCell className="operator" sortBy={sortBy.Operator}>Operator</TableCell>
<TableCell className="value" sortBy={sortBy.Value}>Value</TableCell>
<TableCell className="effect" sortBy={sortBy.Effect}>Effect</TableCell>
<TableCell className="seconds" sortBy={sortBy.Seconds}>Seconds</TableCell>
</TableHead>
</Table>
);
}
@@ -0,0 +1,24 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
.RuntimeClasses {
.TableCell {
&.name {
flex: 2;
}
&.warning {
@include table-cell-warning;
}
&.keys {
flex: 2.5;
}
&.age {
flex: .5;
}
}
}
@@ -0,0 +1,83 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./runtime-classes.scss";
import * as React from "react";
import { observer } from "mobx-react";
import type { RuntimeClass } from "../../../common/k8s-api/endpoints/runtime-class.api";
import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import type { KubeObjectDetailsProps } from "../kube-object-details";
import { KubeObjectListLayout } from "../kube-object-list-layout";
import { SiblingsInTabLayout } from "../layout/siblings-in-tab-layout";
import { KubeObjectAge } from "../kube-object/age";
import { autoBind } from "../../../common/utils";
import { withInjectables } from "@ogre-tools/injectable-react";
import runtimeClassStoreInjectable from "./store.injectable";
import type { RuntimeClassStore } from "./store";
enum columnId {
name = "name",
handler = "handler",
age = "age",
}
export interface RuntimeClassesProps extends KubeObjectDetailsProps<RuntimeClass> {
}
interface Dependencies {
runtimeClassStore: RuntimeClassStore;
}
@observer
class NonInjectedRuntimeClasses extends React.Component<RuntimeClassesProps & Dependencies> {
constructor(props: RuntimeClassesProps & Dependencies) {
super(props);
autoBind(this);
}
render() {
const { runtimeClassStore } = this.props;
return (
<SiblingsInTabLayout>
<KubeObjectListLayout
isConfigurable
tableId="configuration_runtime_classes"
className="RuntimeClasses"
store={runtimeClassStore}
sortingCallbacks={{
[columnId.name]: rc => rc.getName(),
[columnId.handler]: rc => rc.getHandler(),
[columnId.age]: rc => -rc.getCreationTimestamp(),
}}
searchFilters={[
rc => rc.getSearchFields(),
]}
renderHeaderTitle="Runtime Classes"
renderTableHeader={[
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ className: "warning", showWithColumn: columnId.name },
{ title: "Handler", className: "handler", sortBy: columnId.handler, id: columnId.handler },
{ title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
]}
renderTableContents={rc => [
rc.getName(),
<KubeObjectStatusIcon key="icon" object={rc} />,
rc.getHandler(),
<KubeObjectAge key="age" object={rc} />,
]}
/>
</SiblingsInTabLayout>
);
}
}
export const RuntimeClasses = withInjectables<Dependencies, RuntimeClassesProps>(NonInjectedRuntimeClasses, {
getProps: (di, props) => ({
...props,
runtimeClassStore: di.inject(runtimeClassStoreInjectable),
}),
});
@@ -0,0 +1,24 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import assert from "assert";
import { kubeObjectStoreInjectionToken } from "../../../common/k8s-api/api-manager/manager.injectable";
import runtimeClassApiInjectable from "../../../common/k8s-api/endpoints/runtime-class.api.injectable";
import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable";
import { RuntimeClassStore } from "./store";
const runtimeClassStoreInjectable = getInjectable({
id: "runtime-class-store",
instantiate: (di) => {
assert(di.inject(storesAndApisCanBeCreatedInjectable), "runtimeClassStore is only available in certain environments");
const api = di.inject(runtimeClassApiInjectable);
return new RuntimeClassStore(api);
},
injectionToken: kubeObjectStoreInjectionToken,
});
export default runtimeClassStoreInjectable;
@@ -0,0 +1,10 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import type { RuntimeClass, RuntimeClassApi } from "../../../common/k8s-api/endpoints/runtime-class.api";
export class RuntimeClassStore extends KubeObjectStore<RuntimeClass, RuntimeClassApi> {
}
@@ -45,11 +45,7 @@ exports[`<CustomResourceDetails /> with a CRD with a boolean field should displa
<span
class="value"
>
<ul>
<li>
false
</li>
</ul>
false
</span>
</div>
</div>
@@ -101,11 +97,7 @@ exports[`<CustomResourceDetails /> with a CRD with a boolean field should displa
<span
class="value"
>
<ul>
<li>
true
</li>
</ul>
true
</span>
</div>
</div>
@@ -157,11 +149,7 @@ exports[`<CustomResourceDetails /> with a CRD with a number field should display
<span
class="value"
>
<ul>
<li>
0
</li>
</ul>
0
</span>
</div>
</div>
@@ -213,11 +201,7 @@ exports[`<CustomResourceDetails /> with a CRD with a number field should display
<span
class="value"
>
<ul>
<li>
1234
</li>
</ul>
1234
</span>
</div>
</div>
@@ -15,11 +15,10 @@ import { KubeObjectMeta } from "../kube-object-meta";
import { Input } from "../input";
import type { AdditionalPrinterColumnsV1 } from "../../../common/k8s-api/endpoints/custom-resource-definition.api";
import { CustomResourceDefinition } from "../../../common/k8s-api/endpoints/custom-resource-definition.api";
import { convertKubectlJsonPathToNodeJsonPath } from "../../utils/jsonPath";
import { safeJSONPathValue } from "../../utils/jsonPath";
import type { KubeObjectMetadata, KubeObjectStatus } from "../../../common/k8s-api/kube-object";
import { KubeObject } from "../../../common/k8s-api/kube-object";
import logger from "../../../common/logger";
import { JSONPath } from "@astronautlabs/jsonpath";
export interface CustomResourceDetailsProps extends KubeObjectDetailsProps<KubeObject> {
crd: CustomResourceDefinition;
@@ -66,7 +65,7 @@ export class CustomResourceDetails extends React.Component<CustomResourceDetails
renderAdditionalColumns(resource: KubeObject, columns: AdditionalPrinterColumnsV1[]) {
return columns.map(({ name, jsonPath }) => (
<DrawerItem key={name} name={name}>
{convertSpecValue(JSONPath.query(resource, convertKubectlJsonPathToNodeJsonPath(jsonPath)))}
{convertSpecValue(safeJSONPathValue(resource, jsonPath))}
</DrawerItem>
));
}
@@ -11,7 +11,7 @@ import { KubeObjectListLayout } from "../kube-object-list-layout";
import type { IComputedValue } from "mobx";
import { computed, makeObservable } from "mobx";
import type { ApiManager } from "../../../common/k8s-api/api-manager";
import { safeJSONPathValue } from "../../utils/jsonPath";
import { formatJSONValue, safeJSONPathValue } from "../../utils/jsonPath";
import { TabLayout } from "../layout/tab-layout-2";
import { withInjectables } from "@ogre-tools/injectable-react";
import customResourcesRouteParametersInjectable from "./custom-resources-route-parameters.injectable";
@@ -73,7 +73,7 @@ class NonInjectedCustomResources extends React.Component<Dependencies> {
[columnId.age]: customResource => -customResource.getCreationTimestamp(),
...Object.fromEntries(extraColumns.map(({ name, jsonPath }) => [
name,
customResource => safeJSONPathValue(customResource, jsonPath),
customResource => formatJSONValue(safeJSONPathValue(customResource, jsonPath)),
])),
}}
searchFilters={[
@@ -16,3 +16,9 @@
.settingsAvatar {
margin: 0 10px;
}
.avatarAndName {
display: flex;
align-items: center;
margin-bottom: var(--margin);
}
@@ -86,7 +86,7 @@ class NonInjectedEntitySettings extends React.Component<Dependencies> {
return (
<>
<div className="flex items-center pb-8">
<div className={styles.avatarAndName}>
<Avatar
title={entity.getName()}
colorHash={`${entity.getName()}-${entity.metadata.source}`}
@@ -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,41 +17,28 @@ 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";
mockWindow();
jest.setTimeout(30000);
jest.mock("fs-extra");
jest.mock("../../notifications");
jest.mock("../../../../common/utils/downloadFile", () => ({
downloadFile: jest.fn(({ url }) => ({
promise: Promise.resolve(),
url,
cancel: () => {},
})),
downloadJson: jest.fn(({ url }) => ({
promise: Promise.resolve({}),
url,
cancel: () => { },
})),
}));
jest.mock("../../../../common/utils/tar");
import type { DeleteFile } from "../../../../common/fs/delete-file.injectable";
import deleteFileInjectable from "../../../../common/fs/delete-file.injectable";
import type { DownloadJson } from "../../../../common/fetch/download-json.injectable";
import type { DownloadBinary } from "../../../../common/fetch/download-binary.injectable";
import downloadJsonInjectable from "../../../../common/fetch/download-json.injectable";
import downloadBinaryInjectable from "../../../../common/fetch/download-binary.injectable";
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>;
let downloadJson: jest.MockedFunction<DownloadJson>;
let downloadBinary: jest.MockedFunction<DownloadBinary>;
beforeEach(() => {
const di = getDiForUnitTesting({ doGeneralOverrides: true });
@@ -62,17 +46,19 @@ 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);
downloadJson = jest.fn().mockImplementation((url) => { throw new Error(`Unexpected call to downloadJson for url=${url}`); });
di.override(downloadJsonInjectable, () => downloadJson);
downloadBinary = jest.fn().mockImplementation((url) => { throw new Error(`Unexpected call to downloadJson for url=${url}`); });
di.override(downloadBinaryInjectable, () => downloadBinary);
extensionLoader = di.inject(extensionLoaderInjectable);
extensionDiscovery = di.inject(extensionDiscoveryInjectable);
@@ -95,10 +81,6 @@ describe("Extensions", () => {
extensionDiscovery.uninstallExtension = jest.fn(() => Promise.resolve());
});
afterEach(() => {
mockFs.restore();
});
it("disables uninstall and disable buttons while uninstalling", async () => {
extensionDiscovery.isLoaded = true;
@@ -123,7 +105,7 @@ describe("Extensions", () => {
// Approve confirm dialog
fireEvent.click(await screen.findByText("Yes"));
await waitFor(() => {
await waitFor(async () => {
expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled();
fireEvent.click(menuTrigger);
expect(screen.getByText("Disable")).toHaveAttribute("aria-disabled", "true");
@@ -137,9 +119,10 @@ describe("Extensions", () => {
render(<Extensions />);
const resolveInstall = observable.box(false);
const url = "https://test.extensionurl/package.tgz";
(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();
@@ -152,13 +135,26 @@ describe("Extensions", () => {
exact: false,
}), {
target: {
value: "https://test.extensionurl/package.tgz",
value: url,
},
});
const doResolve = observable.box(false);
downloadBinary.mockImplementation(async (targetUrl) => {
expect(targetUrl).toBe(url);
await when(() => doResolve.get());
return {
callWasSuccessful: false,
error: "unknown location",
};
});
fireEvent.click(await screen.findByText("Install"));
expect((await screen.findByText("Install")).closest("button")).toBeDisabled();
resolveInstall.set(true);
doResolve.set(true);
});
it("displays spinner while extensions are loading", () => {
@@ -2,22 +2,23 @@
* 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 { isObject } from "../../../common/utils";
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";
import { withTimeout } from "../../../common/fetch/timeout-controller";
import downloadBinaryInjectable from "../../../common/fetch/download-binary.injectable";
import downloadJsonInjectable from "../../../common/fetch/download-json.injectable";
import type { PackageJson } from "type-fest";
import showErrorNotificationInjectable from "../notifications/show-error-notification.injectable";
import loggerInjectable from "../../../common/logger.injectable";
export interface ExtensionInfo {
name: string;
@@ -25,128 +26,180 @@ export interface ExtensionInfo {
requireConfirmation?: boolean;
}
export type AttemptInstallByInfo = (info: ExtensionInfo) => Promise<void>;
interface Dependencies {
attemptInstall: (request: InstallRequest, d: ExtendableDisposer) => Promise<void>;
getBaseRegistryUrl: () => Promise<string>;
extensionInstallationStateStore: ExtensionInstallationStateStore;
confirm: Confirm;
interface NpmPackageVersionDescriptor extends PackageJson {
dist: {
integrity: string;
shasum: string;
tarball: string;
};
}
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;
interface NpmRegistryPackageDescriptor {
versions: Partial<Record<string, NpmPackageVersionDescriptor>>;
"dist-tags"?: Partial<Record<string, string>>;
}
try {
json = await downloadJson({ url: registryUrl }).promise;
export type AttemptInstallByInfo = (info: ExtensionInfo) => Promise<void>;
if (!json || json.error || typeof json.versions !== "object" || !json.versions) {
const message = json?.error ? `: ${json.error}` : "";
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 downloadJson = di.inject(downloadJsonInjectable);
const downloadBinary = di.inject(downloadBinaryInjectable);
const showErrorNotification = di.inject(showErrorNotificationInjectable);
const logger = di.inject(loggerInjectable);
Notifications.error(`Failed to get registry information for that extension${message}`);
return async (info) => {
const { name, version: versionOrTagName, requireConfirmation = false } = info;
const disposer = extensionInstallationStateStore.startPreInstall();
const baseUrl = await getBaseRegistryUrl();
const registryUrl = new URLParse(baseUrl).set("pathname", name).toString();
let json: NpmRegistryPackageDescriptor;
try {
const result = await downloadJson(registryUrl);
if (!result.callWasSuccessful) {
showErrorNotification(`Failed to get registry information for extension: ${result.error}`);
return disposer();
}
if (!isObject(result.response) || Array.isArray(result.response)) {
showErrorNotification("Failed to get registry information for extension");
return disposer();
}
if (result.response.error || !isObject(result.response.versions)) {
const message = result.response.error ? `: ${result.response.error}` : "";
showErrorNotification(`Failed to get registry information for extension${message}`);
return disposer();
}
json = result.response as unknown as NpmRegistryPackageDescriptor;
} catch (error) {
if (error instanceof SyntaxError) {
// assume invalid JSON
logger.warn("Set registry has invalid json", { url: baseUrl }, error);
showErrorNotification("Failed to get valid registry information for extension. Registry did not return valid JSON");
} else {
logger.error("Failed to download registry information", error);
showErrorNotification(`Failed to get valid registry information for extension. ${error}`);
}
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();
}
let version = versionOrTagName;
if (version) {
if (!json.versions[version]) {
if (json["dist-tags"][version]) {
finalVersion = json["dist-tags"][version];
} else {
Notifications.error((
if (versionOrTagName) {
validDistTagName:
if (!json.versions[versionOrTagName]) {
if (json["dist-tags"]) {
const potentialVersion = json["dist-tags"][versionOrTagName];
if (potentialVersion) {
if (!json.versions[potentialVersion]) {
showErrorNotification((
<p>
Configured registry claims to have tag
{" "}
<code>{versionOrTagName}</code>
.
{" "}
But does not have version infomation for the reference.
</p>
));
return disposer();
}
version = potentialVersion;
break validDistTagName;
}
}
showErrorNotification((
<p>
{"The "}
<em>{name}</em>
{" extension does not have a version or tag "}
<code>{version}</code>
<code>{versionOrTagName}</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);
const latestVersion = reduce(versions, (prev, curr) => prev.compareMain(curr) === -1 ? curr : prev);
version = latestVersion?.format();
}
} 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);
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 (!version) {
logger.error("No versions supplied for extension", { name });
showErrorNotification(`No versions found for ${name}`);
return disposer();
}
finalVersion = latestVersion.format();
}
const versionInfo = json.versions[version];
const tarballUrl = versionInfo?.dist.tarball;
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 (!tarballUrl) {
showErrorNotification("Configured registry has invalid data model. Please verify that it is like NPM's.");
logger.warn(`[ATTEMPT-INSTALL-BY-INFO]: registry returned unexpected data, final version is ${version} but the versions object is missing .dist.tarball as a string`, versionInfo);
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 });
if (requireConfirmation) {
const proceed = await confirm({
message: (
<p>
{"Are you sure you want to install "}
<b>
{`${name}@${version}`}
</b>
?
</p>
),
labelCancel: "Cancel",
labelOk: "Install",
});
return attemptInstall({ fileName, dataP }, disposer);
}
);
if (!proceed) {
return 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),
}),
const fileName = getBasenameOfPath(tarballUrl);
const { signal } = withTimeout(10 * 60 * 1000);
const request = await downloadBinary(tarballUrl, { signal });
if (!request.callWasSuccessful) {
showErrorNotification(`Failed to download extension: ${request.error}`);
return disposer();
}
return attemptInstall({ fileName, data: request.response }, disposer);
};
},
});
export default attemptInstallByInfoInjectable;
@@ -1,30 +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 extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable";
import uninstallExtensionInjectable from "../uninstall-extension/uninstall-extension.injectable";
import { attemptInstall } from "./attempt-install";
import unpackExtensionInjectable from "./unpack-extension/unpack-extension.injectable";
import getExtensionDestFolderInjectable
from "./get-extension-dest-folder/get-extension-dest-folder.injectable";
import createTempFilesAndValidateInjectable from "./create-temp-files-and-validate/create-temp-files-and-validate.injectable";
import extensionInstallationStateStoreInjectable
from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
const attemptInstallInjectable = getInjectable({
id: "attempt-install",
instantiate: (di) =>
attemptInstall({
extensionLoader: di.inject(extensionLoaderInjectable),
uninstallExtension: di.inject(uninstallExtensionInjectable),
unpackExtension: di.inject(unpackExtensionInjectable),
createTempFilesAndValidate: di.inject(createTempFilesAndValidateInjectable),
getExtensionDestFolder: di.inject(getExtensionDestFolderInjectable),
extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable),
}),
});
export default attemptInstallInjectable;
@@ -0,0 +1,148 @@
/**
* 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 extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable";
import uninstallExtensionInjectable from "../uninstall-extension/uninstall-extension.injectable";
import type { UnpackExtension } from "./unpack-extension.injectable";
import unpackExtensionInjectable from "./unpack-extension.injectable";
import type { GetExtensionDestFolder } from "./get-extension-dest-folder.injectable";
import getExtensionDestFolderInjectable from "./get-extension-dest-folder.injectable";
import type { CreateTempFilesAndValidate } from "./create-temp-files-and-validate.injectable";
import createTempFilesAndValidateInjectable from "./create-temp-files-and-validate.injectable";
import extensionInstallationStateStoreInjectable from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import type { Disposer } from "../../../../common/utils";
import { disposer } from "../../../../common/utils";
import { Notifications } from "../../notifications";
import { Button } from "../../button";
import type { ExtensionLoader } from "../../../../extensions/extension-loader";
import type { LensExtensionId } from "../../../../extensions/lens-extension";
import React from "react";
import { remove as removeDir } from "fs-extra";
import { shell } from "electron";
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";
export interface InstallRequest {
fileName: string;
data: Buffer;
}
interface Dependencies {
extensionLoader: ExtensionLoader;
uninstallExtension: (id: LensExtensionId) => Promise<boolean>;
unpackExtension: UnpackExtension;
createTempFilesAndValidate: CreateTempFilesAndValidate;
getExtensionDestFolder: GetExtensionDestFolder;
installStateStore: ExtensionInstallationStateStore;
}
export type AttemptInstall = (request: InstallRequest, cleanup?: Disposer) => Promise<void>;
const attemptInstall = ({
extensionLoader,
uninstallExtension,
unpackExtension,
createTempFilesAndValidate,
getExtensionDestFolder,
installStateStore,
}: Dependencies): AttemptInstall =>
async (request, cleanup) => {
const dispose = disposer(
installStateStore.startPreInstall(),
cleanup,
);
const validatedRequest = await createTempFilesAndValidate(request);
if (!validatedRequest) {
return dispose();
}
const { name, version, description } = validatedRequest.manifest;
const curState = installStateStore.getInstallationState(validatedRequest.id);
if (curState !== ExtensionInstallationState.IDLE) {
dispose();
return void Notifications.error(
<div className="flex column gaps">
<b>Extension Install Collision:</b>
<p>
{"The "}
<em>{name}</em>
{` extension is currently ${curState.toLowerCase()}.`}
</p>
<p>Will not proceed with this current install request.</p>
</div>,
);
}
const extensionFolder = getExtensionDestFolder(name);
const installedExtension = extensionLoader.getExtension(validatedRequest.id);
if (installedExtension) {
const { version: oldVersion } = installedExtension.manifest;
// confirm to uninstall old version before installing new version
const removeNotification = Notifications.info(
<div className="InstallingExtensionNotification flex gaps align-center">
<div className="flex column gaps">
<p>
{"Install extension "}
<b>{`${name}@${version}`}</b>
?
</p>
<p>
{"Description: "}
<em>{description}</em>
</p>
<div
className="remove-folder-warning"
onClick={() => shell.openPath(extensionFolder)}
>
<b>Warning:</b>
{` ${name}@${oldVersion} will be removed before installation.`}
</div>
</div>
<Button
autoFocus
label="Install"
onClick={async () => {
removeNotification();
if (await uninstallExtension(validatedRequest.id)) {
await unpackExtension(validatedRequest, dispose);
} else {
dispose();
}
}}
/>
</div>,
{
onClose: dispose,
},
);
} else {
// clean up old data if still around
await removeDir(extensionFolder);
// install extension if not yet exists
await unpackExtension(validatedRequest, dispose);
}
};
const attemptInstallInjectable = getInjectable({
id: "attempt-install",
instantiate: (di) => attemptInstall({
extensionLoader: di.inject(extensionLoaderInjectable),
uninstallExtension: di.inject(uninstallExtensionInjectable),
unpackExtension: di.inject(unpackExtensionInjectable),
createTempFilesAndValidate: di.inject(createTempFilesAndValidateInjectable),
getExtensionDestFolder: di.inject(getExtensionDestFolderInjectable),
installStateStore: di.inject(extensionInstallationStateStoreInjectable),
}),
});
export default attemptInstallInjectable;
@@ -1,138 +0,0 @@
/**
* 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 { Notifications } from "../../notifications";
import { Button } from "../../button";
import type { ExtensionLoader } from "../../../../extensions/extension-loader";
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";
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;
extensionInstallationStateStore: ExtensionInstallationStateStore;
}
export const attemptInstall =
({
extensionLoader,
uninstallExtension,
unpackExtension,
createTempFilesAndValidate,
getExtensionDestFolder,
extensionInstallationStateStore,
}: Dependencies) =>
async (request: InstallRequest, d?: ExtendableDisposer): Promise<void> => {
const dispose = disposer(
extensionInstallationStateStore.startPreInstall(),
d,
);
const validatedRequest = await createTempFilesAndValidate(request);
if (!validatedRequest) {
return dispose();
}
const { name, version, description } = validatedRequest.manifest;
const curState = extensionInstallationStateStore.getInstallationState(
validatedRequest.id,
);
if (curState !== ExtensionInstallationState.IDLE) {
dispose();
return void Notifications.error(
<div className="flex column gaps">
<b>Extension Install Collision:</b>
<p>
{"The "}
<em>{name}</em>
{` extension is currently ${curState.toLowerCase()}.`}
</p>
<p>Will not proceed with this current install request.</p>
</div>,
);
}
const extensionFolder = getExtensionDestFolder(name);
const installedExtension = extensionLoader.getExtension(validatedRequest.id);
if (installedExtension) {
const { version: oldVersion } = installedExtension.manifest;
// confirm to uninstall old version before installing new version
const removeNotification = Notifications.info(
<div className="InstallingExtensionNotification flex gaps align-center">
<div className="flex column gaps">
<p>
{"Install extension "}
<b>{`${name}@${version}`}</b>
?
</p>
<p>
{"Description: "}
<em>{description}</em>
</p>
<div
className="remove-folder-warning"
onClick={() => shell.openPath(extensionFolder)}
>
<b>Warning:</b>
{` ${name}@${oldVersion} will be removed before installation.`}
</div>
</div>
<Button
autoFocus
label="Install"
onClick={async () => {
removeNotification();
if (await uninstallExtension(validatedRequest.id)) {
await unpackExtension(validatedRequest, dispose);
} else {
dispose();
}
}}
/>
</div>,
{
onClose: dispose,
},
);
} else {
// clean up old data if still around
await removeDir(extensionFolder);
// install extension if not yet exists
await unpackExtension(validatedRequest, dispose);
}
};
@@ -0,0 +1,97 @@
/**
* 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 extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable";
import { validatePackage } from "./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 "./attempt-install.injectable";
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 type CreateTempFilesAndValidate = (request: InstallRequest) => Promise<InstallRequestValidated | null>;
const createTempFilesAndValidate = ({
extensionDiscovery,
}: Dependencies): CreateTempFilesAndValidate => (
async ({ fileName, data }) => {
// copy files to temp
await fse.ensureDir(getExtensionPackageTemp());
// validate packages
const tempFile = getExtensionPackageTemp(fileName);
try {
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);
}
const createTempFilesAndValidateInjectable = getInjectable({
id: "create-temp-files-and-validate",
instantiate: (di) => createTempFilesAndValidate({
extensionDiscovery: di.inject(extensionDiscoveryInjectable),
}),
});
export default createTempFilesAndValidateInjectable;
@@ -1,18 +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 { createTempFilesAndValidate } from "./create-temp-files-and-validate";
import extensionDiscoveryInjectable from "../../../../../extensions/extension-discovery/extension-discovery.injectable";
const createTempFilesAndValidateInjectable = getInjectable({
id: "create-temp-files-and-validate",
instantiate: (di) =>
createTempFilesAndValidate({
extensionDiscovery: di.inject(extensionDiscoveryInjectable),
}),
});
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);
}
@@ -0,0 +1,22 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable";
import { sanitizeExtensionName } from "../../../../extensions/lens-extension";
import path from "path";
export type GetExtensionDestFolder = (name: string) => string;
const getExtensionDestFolderInjectable = getInjectable({
id: "get-extension-dest-folder",
instantiate: (di): GetExtensionDestFolder => {
const extensionDiscovery = di.inject(extensionDiscoveryInjectable);
return (name) => path.join(extensionDiscovery.localFolderPath, sanitizeExtensionName(name));
},
});
export default getExtensionDestFolderInjectable;
@@ -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 extensionDiscoveryInjectable from "../../../../../extensions/extension-discovery/extension-discovery.injectable";
import { getExtensionDestFolder } from "./get-extension-dest-folder";
const getExtensionDestFolderInjectable = getInjectable({
id: "get-extension-dest-folder",
instantiate: (di) =>
getExtensionDestFolder({
extensionDiscovery: di.inject(extensionDiscoveryInjectable),
}),
});
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));
@@ -1,8 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export interface InstallRequest {
fileName: string;
dataP: Promise<Buffer | null>;
}
@@ -2,33 +2,38 @@
* 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 { getInjectable } from "@ogre-tools/injectable";
import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable";
import getExtensionDestFolderInjectable from "./get-extension-dest-folder.injectable";
import extensionInstallationStateStoreInjectable from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import type { Disposer } from "../../../../common/utils";
import { noop } from "../../../../common/utils";
import { extensionDisplayName } from "../../../../extensions/lens-extension";
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";
import type { InstallRequestValidated } from "./create-temp-files-and-validate.injectable";
import extractTarInjectable from "../../../../common/fs/extract-tar.injectable";
import loggerInjectable from "../../../../common/logger.injectable";
import showInfoNotificationInjectable from "../../notifications/show-info-notification.injectable";
import showErrorNotificationInjectable from "../../notifications/show-error-notification.injectable";
interface Dependencies {
extensionLoader: ExtensionLoader;
getExtensionDestFolder: (name: string) => string;
extensionInstallationStateStore: ExtensionInstallationStateStore;
}
export type UnpackExtension = (request: InstallRequestValidated, disposeDownloading?: Disposer) => Promise<void>;
export const unpackExtension =
({
extensionLoader,
getExtensionDestFolder,
extensionInstallationStateStore,
}: Dependencies) =>
async (request: InstallRequestValidated, disposeDownloading?: Disposer) => {
const unpackExtensionInjectable = getInjectable({
id: "unpack-extension",
instantiate: (di): UnpackExtension => {
const extensionLoader = di.inject(extensionLoaderInjectable);
const getExtensionDestFolder = di.inject(getExtensionDestFolderInjectable);
const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
const extractTar = di.inject(extractTarInjectable);
const logger = di.inject(loggerInjectable);
const showInfoNotification = di.inject(showInfoNotificationInjectable);
const showErrorNotification = di.inject(showErrorNotificationInjectable);
return async (request, disposeDownloading) => {
const {
id,
fileName,
@@ -73,13 +78,13 @@ export const unpackExtension =
// Enable installed extensions by default.
extensionLoader.setIsEnabled(id, true);
Notifications.ok(
showInfoNotification((
<p>
{"Extension "}
<b>{displayName}</b>
{" successfully installed!"}
</p>,
);
</p>
));
} catch (error) {
const message = getMessageFromError(error);
@@ -87,14 +92,14 @@ export const unpackExtension =
`[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`,
{ error },
);
Notifications.error(
showErrorNotification((
<p>
{"Installing extension "}
<b>{displayName}</b>
{" has failed: "}
<em>{message}</em>
</p>,
);
</p>
));
} finally {
// Remove install state once finished
extensionInstallationStateStore.clearInstalling(id);
@@ -104,3 +109,7 @@ export const unpackExtension =
fse.unlink(tempFile).catch(noop);
}
};
},
});
export default unpackExtensionInjectable;
@@ -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 { 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";
const unpackExtensionInjectable = getInjectable({
id: "unpack-extension",
instantiate: (di) =>
unpackExtension({
extensionLoader: di.inject(extensionLoaderInjectable),
getExtensionDestFolder: di.inject(getExtensionDestFolderInjectable),
extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable),
}),
});
export default unpackExtensionInjectable;
@@ -2,27 +2,23 @@
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { LensExtensionManifest } from "../../../../../extensions/lens-extension";
import { hasTypedProperty, isObject, isString, listTarEntries, readFileFromTar } from "../../../../../common/utils";
import { manifestFilename } from "../../../../../extensions/extension-discovery/extension-discovery";
import type { LensExtensionManifest } from "../../../../extensions/lens-extension";
import { hasTypedProperty, isObject, isString, listTarEntries, readFileFromTar } from "../../../../common/utils";
import { manifestFilename } from "../../../../extensions/extension-discovery/extension-discovery";
import path from "path";
export const validatePackage = async (
filePath: string,
): Promise<LensExtensionManifest> => {
export async function validatePackage(filePath: string): Promise<LensExtensionManifest> {
const tarFiles = await listTarEntries(filePath);
// tarball from npm contains single root folder "package/*"
const firstFile = tarFiles[0];
if (!firstFile) {
throw new Error(`invalid extension bundle, ${manifestFilename} not found`);
throw new Error(`invalid extension bundle, ${manifestFilename} not found`);
}
const rootFolder = path.normalize(firstFile).split(path.sep)[0];
const packedInRootFolder = tarFiles.every(entry =>
entry.startsWith(rootFolder),
);
const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder));
const manifestLocation = packedInRootFolder
? path.join(rootFolder, manifestFilename)
: manifestFilename;
@@ -48,4 +44,4 @@ export const validatePackage = async (
}
throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`);
};
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import attemptInstallInjectable from "./attempt-install/attempt-install.injectable";
import path from "path";
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 => {
const attemptInstall = di.inject(attemptInstallInjectable);
const readFileNotify = di.inject(readFileNotifyInjectable);
return async (filePaths) => {
await Promise.allSettled(
filePaths.map(async filePath => {
const data = await readFileNotify(filePath);
if (!data) {
return;
}
return attemptInstall({
fileName: path.basename(filePath),
data,
});
}),
);
};
},
});
export default attemptInstallsInjectable;
@@ -1,18 +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 { attemptInstalls } from "./attempt-installs";
import attemptInstallInjectable from "../attempt-install/attempt-install.injectable";
const attemptInstallsInjectable = getInjectable({
id: "attempt-installs",
instantiate: (di) =>
attemptInstalls({
attemptInstall: di.inject(attemptInstallInjectable),
}),
});
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,23 +28,24 @@ 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 type { InstallOnDrop } from "./install-on-drop.injectable";
import installOnDropInjectable from "./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>;
installOnDrop: InstallOnDrop;
extensionInstallationStateStore: ExtensionInstallationStateStore;
}
@@ -107,7 +108,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 +132,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,97 @@
/**
* 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 { 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";
import downloadBinaryInjectable from "../../../common/fetch/download-binary.injectable";
import { withTimeout } from "../../../common/fetch/timeout-controller";
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);
const downloadBinary = di.inject(downloadBinaryInjectable);
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 { signal } = withTimeout(10 * 60 * 1000);
const result = await downloadBinary(input, { signal });
if (!result.callWasSuccessful) {
showErrorNotification(`Failed to download extension: ${result.error}`);
return disposer();
}
const fileName = getBasenameOfPath(input);
return await attemptInstall({ fileName, data: result.response }, disposer);
}
try {
await InputValidators.isPath.validate(input);
// install from system path
const fileName = getBasenameOfPath(input);
const data = await readFileNotify(input);
if (!data) {
return;
}
return await attemptInstall({ fileName, data });
} 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?.();
}
}
);
@@ -5,7 +5,7 @@
import { getInjectable } from "@ogre-tools/injectable";
import { requestOpenFilePickingDialog } from "../../ipc";
import { supportedExtensionFormats } from "./supported-extension-formats";
import attemptInstallsInjectable from "./attempt-installs/attempt-installs.injectable";
import attemptInstallsInjectable from "./attempt-installs.injectable";
import directoryForDownloadsInjectable from "../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable";
interface Dependencies {
@@ -0,0 +1,26 @@
/**
* 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 loggerInjectable from "../../../common/logger.injectable";
import attemptInstallsInjectable from "./attempt-installs.injectable";
export type InstallOnDrop = (files: File[]) => Promise<void>;
const installOnDropInjectable = getInjectable({
id: "install-on-drop",
instantiate: (di): InstallOnDrop => {
const attemptInstalls = di.inject(attemptInstallsInjectable);
const logger = di.inject(loggerInjectable);
return (files) => {
logger.info("Install from D&D");
return attemptInstalls(files.map(({ path }) => path));
};
},
});
export default installOnDropInjectable;
@@ -1,18 +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 { installOnDrop } from "./install-on-drop";
import attemptInstallsInjectable from "../attempt-installs/attempt-installs.injectable";
const installOnDropInjectable = getInjectable({
id: "install-on-drop",
instantiate: (di) =>
installOnDrop({
attemptInstalls: di.inject(attemptInstallsInjectable),
}),
});
export default installOnDropInjectable;
@@ -1,16 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import logger from "../../../../main/logger";
interface Dependencies {
attemptInstalls: (filePaths: string[]) => Promise<void>;
}
export const installOnDrop =
({ attemptInstalls }: Dependencies) =>
async (files: File[]) => {
logger.info("Install from D&D");
await attemptInstalls(files.map(({ path }) => path));
};
@@ -56,9 +56,7 @@ const NonInjectedInstall: React.FC<Dependencies & InstallProps> = ({
<div>
<Input
theme="round-black"
disabled={
extensionInstallationStateStore.anyPreInstallingOrInstalling
}
disabled={extensionInstallationStateStore.anyPreInstallingOrInstalling}
placeholder={"Name or file path or URL"}
showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }}
validators={installPath ? installInputValidator : undefined}
@@ -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,30 +4,37 @@
*/
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 selection = di.inject(
helmChartDetailsVersionSelectionInjectable,
chart,
);
const requestHelmChartReadme = di.inject(requestHelmChartReadmeInjectable);
return asyncComputed(async () => {
const chartVersion = selection.value.get();
return asyncComputed({
getValueFromObservedPromise: async () => {
const chartVersion = selection.value.get();
if (!chartVersion) {
return "";
}
if (!chartVersion) {
return "";
}
return await callForHelmChartReadme(
chartVersion.getRepository(),
chartVersion.getName(),
chartVersion.getVersion(),
);
}, "");
return await requestHelmChartReadme(
chartVersion.getRepository(),
chartVersion.getName(),
chartVersion.getVersion(),
);
},
valueWhenPending: "",
});
},
lifecycle: lifecycleEnum.keyedSingleton({
@@ -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,20 +4,23 @@
*/
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);
return asyncComputed(
async () =>
await callForHelmChartVersions(chart.getRepository(), chart.getName()),
[],
const requestHelmChartVersions = di.inject(
requestHelmChartVersionsInjectable,
);
return asyncComputed({
getValueFromObservedPromise: async () =>
await requestHelmChartVersions(chart.getRepository(), chart.getName()),
valueWhenPending: [],
});
},
lifecycle: lifecycleEnum.keyedSingleton({
@@ -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;
@@ -117,16 +117,10 @@ class NonInjectedHelmChartDetails extends Component<HelmChartDetailsProps & Depe
</a>
</DrawerItem>
<DrawerItem name="Maintainers" className="maintainers">
{selectedChart.getMaintainers().map(({ name, email, url }) => (
<a
key={name}
href={url || `mailto:${email}`}
target="_blank"
rel="noreferrer"
>
{name}
</a>
))}
{selectedChart.getMaintainers()
.map(({ name, email }) => (
<li key={name}>{`${name}<${email}>`}</li>
))}
</DrawerItem>
{selectedChart.getKeywords().length > 0 && (
<DrawerItem name="Keywords" labelsOnly>
@@ -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,18 @@
*/
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({
getValueFromObservedPromise: requestHelmCharts,
valueWhenPending: [],
});
},
});
@@ -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,33 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { 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({
getValueFromObservedPromise: 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;
}
Loaded 100 of 1328 files, more files were not shown because too many files have changed in this diff. Show more