diff --git a/docs/extensions/capabilities/styling.md b/docs/extensions/capabilities/styling.md index e4d001b5a3..236e8be120 100644 --- a/docs/extensions/capabilities/styling.md +++ b/docs/extensions/capabilities/styling.md @@ -104,38 +104,38 @@ A complete list of themable colors can be found in the [Color Reference](../colo When the light theme is active, the `` element gets a "theme-light" class, or: ``. If the class isn't there, the theme defaults to dark. The active theme can be changed in the **Preferences** page: ![Color Theme](images/theme-selector.png) -Currently, there is no prescribed way of detecting changes to the theme in JavaScript. [This issue](https://github.com/lensapp/lens/issues/1336) has been raised to resolve this problem. In the meantime, you can use a [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) in order to observe the `` element's `class` attribute in order to see if the "theme-light" class gets added to it: +There is a way of detect active theme and its changes in JS. [MobX observer function/decorator](https://github.com/mobxjs/mobx-react#observercomponent) can be used for this purpose. -```javascript -... - useEffect(function () { - const observer = new MutationObserver(function (mutations: MutationRecord[]) { - mutations.forEach((mutation: MutationRecord) => { - if (mutation.type === 'attributes' && mutation.attributeName === 'class') { - if ((mutation.target as HTMLElement).classList.contains('theme-light')) { - // theme is LIGHT - } else { - // theme is DARK - } - } - }); - }); +```js +import React from "react" +import { observer } from "mobx-react" +import { App, Component, Theme } from "@k8slens/extensions"; - observer.observe(document.body, { - attributes: true, - attributeFilter: ['class'], - }); - - return function () { - observer.disconnect(); - }; - }, []); // run once on mount -... +@observer +export class SupportPage extends React.Component { + render() { + return ( +
+

Active theme is {Theme.getActiveTheme().name}

+
+ ); + } +} ``` +`Theme` entity from `@k8slens/extensions` provides active theme object and `@observer` decorator makes component reactive - so it will rerender each time any of the observables (active theme in our case) will be changed. + +Working example provided in [Styling with Emotion](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) sample extension. + ## Injected Styles -Every extension is affected by the list of default global styles defined in [app.scss](https://github.com/lensapp/lens/blob/master/src/renderer/components/app.scss). These are basic browser resets and element styles, including setting the `box-sizing` property for every element, default text and background colors, default font sizes, basic heading formatting, and so on. +Every extension is affected by the list of default global styles defined in [app.scss](https://github.com/lensapp/lens/blob/master/src/renderer/components/app.scss). These are basic browser resets and element styles, including: + +- setting the `box-sizing` property for every element +- default text and background colors +- default font sizes +- basic heading (h1, h2, etc) formatting +- custom scrollbar styling Extensions may overwrite these defaults if needed. They have low CSS specificity, so overriding them should be fairly easy. @@ -148,3 +148,11 @@ const Container = styled.div(() => ({ backgroundColor: 'var(--mainBackground)' })); ``` + +## Examples + +You can explore samples for each styling technique that you can use for extensions: + +- [Styling with Sass](https://github.com/lensapp/lens-extension-samples/tree/master/styling-sass-sample) +- [Styling with Emotion](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) +- [Styling with CSS Modules](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) diff --git a/docs/extensions/guides/README.md b/docs/extensions/guides/README.md index 2f04edfea1..0e15268611 100644 --- a/docs/extensions/guides/README.md +++ b/docs/extensions/guides/README.md @@ -14,6 +14,7 @@ Each guide or sample will include: | Guide | APIs | | ----- | ----- | +| [Generate new extension project](generator.md) || | [Main process extension](main-extension.md) | LensMainExtension | | [Renderer process extension](renderer-extension.md) | LensRendererExtension | | [Stores](stores.md) | | @@ -27,3 +28,7 @@ Each guide or sample will include: | ----- | ----- | [helloworld](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps | [minikube](https://github.com/lensapp/lens-extension-samples/tree/master/minikube-sample) | LensMainExtension
Store.clusterStore
Store.workspaceStore | +[styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps | +[styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps | +[styling-sass-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-sass-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps | +[custom-resource-page](https://github.com/lensapp/lens-extension-samples/tree/master/custom-resource-page) | LensRendererExtension
K8sApi.KubeApi
K8sApi.KubeObjectStore
Component.KubeObjectListLayout
Component.KubeObjectDetailsProps
Component.IconProps | diff --git a/docs/extensions/guides/renderer-extension.md b/docs/extensions/guides/renderer-extension.md index 8d02fb9cef..1e595d004f 100644 --- a/docs/extensions/guides/renderer-extension.md +++ b/docs/extensions/guides/renderer-extension.md @@ -269,7 +269,115 @@ export class HelpPage extends React.Component<{ extension: LensRendererExtension `HelpIcon` introduces one of Lens' built-in components available to extension developers, the `Component.Icon`. Built in are the [Material Design](https://material.io) [icons](https://material.io/resources/icons/). One can be selected by name via the `material` field. +### `clusterFeatures` +Cluster features are Kubernetes resources that can be applied to and managed within the active cluster. They can be installed/uninstalled by the Lens user from the [cluster settings page](). +The following example shows how to add a cluster feature as part of a `LensRendererExtension`: + +``` typescript +import { LensRendererExtension } from "@k8slens/extensions" +import { ExampleFeature } from "./src/example-feature" +import React from "react" + +export default class ExampleFeatureExtension extends LensRendererExtension { + clusterFeatures = [ + { + title: "Example Feature", + components: { + Description: () => { + return ( + + Enable an example feature. + + ) + } + }, + feature: new ExampleFeature() + } + ]; +} +``` +The `title` and `components.Description` fields provide content that appears on the cluster settings page, in the **Features** section. The `feature` field must specify an instance which extends the abstract class `ClusterFeature.Feature`, and specifically implement the following methods: + +``` typescript + abstract install(cluster: Cluster): Promise; + abstract upgrade(cluster: Cluster): Promise; + abstract uninstall(cluster: Cluster): Promise; + abstract updateStatus(cluster: Cluster): Promise; +``` + +The `install()` method is typically called by Lens when a user has indicated that this feature is to be installed (i.e. clicked **Install** for the feature on the cluster settings page). The implementation of this method should install kubernetes resources using the `applyResources()` method, or by directly accessing the kubernetes api ([`K8sApi`](tbd)). + +The `upgrade()` method is typically called by Lens when a user has indicated that this feature is to be upgraded (i.e. clicked **Upgrade** for the feature on the cluster settings page). The implementation of this method should upgrade the kubernetes resources already installed, if relevant to the feature. + +The `uninstall()` method is typically called by Lens when a user has indicated that this feature is to be uninstalled (i.e. clicked **Uninstall** for the feature on the cluster settings page). The implementation of this method should uninstall kubernetes resources using the kubernetes api (`K8sApi`) + +The `updateStatus()` method is called periodically by Lens to determine details about the feature's current status. The implementation of this method should provide the current status information in the `status` field of the `ClusterFeature.Feature` parent class. The `status.currentVersion` and `status.latestVersion` fields may be displayed by Lens in describing the feature. The `status.installed` field should be set to true if the feature is currently installed, otherwise false. Also, Lens relies on the `status.canUpgrade` field to determine if the feature can be upgraded (i.e a new version could be available) so the implementation should set the `status.canUpgrade` field according to specific rules for the feature, if relevant. + +The following shows a very simple implementation of a `ClusterFeature`: + +``` typescript +import { ClusterFeature, Store, K8sApi } from "@k8slens/extensions"; +import * as path from "path"; + +export class ExampleFeature extends ClusterFeature.Feature { + + async install(cluster: Store.Cluster): Promise { + + super.applyResources(cluster, path.join(__dirname, "../resources/")); + } + + async upgrade(cluster: Store.Cluster): Promise { + return this.install(cluster); + } + + async updateStatus(cluster: Store.Cluster): Promise { + try { + const pod = K8sApi.forCluster(cluster, K8sApi.Pod); + const examplePod = await pod.get({name: "example-pod", namespace: "default"}); + if (examplePod?.kind) { + this.status.installed = true; + this.status.currentVersion = examplePod.spec.containers[0].image.split(":")[1]; + this.status.canUpgrade = true; // a real implementation would perform a check here that is relevant to the specific feature + } else { + this.status.installed = false; + this.status.canUpgrade = false; + } + } catch(e) { + if (e?.error?.code === 404) { + this.status.installed = false; + this.status.canUpgrade = false; + } + } + + return this.status; + } + + async uninstall(cluster: Store.Cluster): Promise { + const podApi = K8sApi.forCluster(cluster, K8sApi.Pod); + await podApi.delete({name: "example-pod", namespace: "default"}); + } +} +``` + +This example implements the `install()` method by simply invoking the helper `applyResources()` method. `applyResources()` tries to apply all resources read from all files found in the folder path provided. In this case this folder path is the `../resources` subfolder relative to current source code's folder. The file `../resources/example-pod.yml` could contain: + +``` yaml +apiVersion: v1 +kind: Pod +metadata: + name: example-pod +spec: + containers: + - name: example-pod + image: nginx +``` + +The `upgrade()` method in the example above is implemented by simply invoking the `install()` method. Depending on the feature to be supported by an extension, upgrading may require additional and/or different steps. + +The `uninstall()` method is implemented in the example above by utilizing the [`K8sApi`](tbd) provided by Lens to simply delete the `example-pod` pod applied by the `install()` method. + +The `updateStatus()` method is implemented above by using the [`K8sApi`](tbd) as well, this time to get information from the `example-pod` pod, in particular to determine if it is installed, what version is associated with it, and if it can be upgraded. How the status is updated for a specific cluster feature is up to the implementation. ********************************************************************* @@ -278,44 +386,6 @@ WIP below! -### `clusterFeatures` - -Cluster features are Kubernetes resources that can applied and managed to the active cluster. They can be installed/uninstalled from the [cluster settings page](). -The following example shows how to add a cluster feature: - -``` typescript -import { LensRendererExtension } from "@k8slens/extensions" -import { MetricsFeature } from "./src/metrics-feature" -import React from "react" - -export default class ClusterMetricsFeatureExtension extends LensRendererExtension { - clusterFeatures = [ - { - title: "Metrics Stack", - components: { - Description: () => { - return ( - - Enable timeseries data visualization (Prometheus stack) for your cluster. - Install this only if you don't have existing Prometheus stack installed. - You can see preview of manifests here. - - ) - } - }, - feature: new MetricsFeature() - } - ]; -} -``` -The `title` and `components.Description` fields appear on the cluster settings page. The cluster feature must extend the abstract class `ClusterFeature.Feature`, and specifically implement the following methods: - -``` typescript - abstract install(cluster: Cluster): Promise; - abstract upgrade(cluster: Cluster): Promise; - abstract uninstall(cluster: Cluster): Promise; - abstract updateStatus(cluster: Cluster): Promise; -``` ### `appPreferences` diff --git a/docs/helm/README.md b/docs/helm/README.md index c6ea505cc3..b46bead65c 100644 --- a/docs/helm/README.md +++ b/docs/helm/README.md @@ -1,3 +1,21 @@ # Using Helm Charts -TBD +Lens has integration to Helm making it easy to install and manage Helm charts and releases in Apps section. + +![Helm Charts](images/helm-charts.png) + +## Managing Helm Reporistories + +Used Helm repositories are possible to configure in the [Preferences](/getting-started/preferences). Lens app will fetch available Helm repositories from the [Artifact HUB](https://artifacthub.io/) and automatically add `bitnami` repository by default if no other repositories are already configured. If any other repositories are needed to add, those can be added manually via command line. **Note!** Configured Helm repositories are added globally to user's computer, so other processes can see those as well. + + +## Installing a Helm Chart + +Lens will list all charts from configured Helm repositries on Apps section. To install a chart, you need to select a chart and click "Install" button. Lens will open the chart in the editor where you can select a chart version, target namespace and give optionally a name for the release and configure values for the release. Finally, by clicking "Install" button Lens will deploy the chart into the cluster. + +## Updating a Helm Release + +To update a Helm release, you can open the release details and modify the release values and click "Save" button. To upgrade or downgrade the release, click "Upgrade" button in the release details. In the release editor you can select a new chart version and edit the release values if needed and then click "Upgrade" or "Upgrade and Close" button. + +## Deleting a Helm Release +To delete existing Helm release open the release details and click trash can icon on the top of the panel. Deletion removes all Kubernetes resources created by the Helm release. **Note!** If the release included any persistent volumes, those are required to remove manually! \ No newline at end of file diff --git a/docs/helm/images/helm-charts.png b/docs/helm/images/helm-charts.png new file mode 100644 index 0000000000..93548fca9e Binary files /dev/null and b/docs/helm/images/helm-charts.png differ diff --git a/package.json b/package.json index 869f7453c3..889ca2cc9c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "kontena-lens", "productName": "Lens", "description": "Lens - The Kubernetes IDE", - "version": "4.0.0-beta.4", + "version": "4.0.0-rc.1", "main": "static/build/main.js", "copyright": "© 2020, Mirantis, Inc.", "license": "MIT", @@ -91,11 +91,6 @@ ], "afterSign": "build/notarize.js", "extraResources": [ - { - "from": "src/features/", - "to": "features/", - "filter": "**/*" - }, { "from": "locales/", "to": "locales/", @@ -116,6 +111,7 @@ "to": "./extensions/", "filter": [ "**/*.js*", + "**/*.yml*", "!**/node_modules" ] }, @@ -236,7 +232,7 @@ "mac-ca": "^1.0.4", "marked": "^1.1.0", "md5-file": "^5.0.0", - "mobx": "^5.15.5", + "mobx": "^5.15.7", "mobx-observable-history": "^1.0.3", "mock-fs": "^4.12.0", "node-pty": "^0.9.0", diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index f0e03b1b12..8c319d3f97 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -7,8 +7,6 @@ import { workspaceStore } from "../workspace-store"; const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png"); -console.log(""); // fix bug - let clusterStore: ClusterStore; describe("empty config", () => { diff --git a/src/common/utils/defineGlobal.ts b/src/common/utils/defineGlobal.ts index 1a4c5993d9..d8883bb38d 100755 --- a/src/common/utils/defineGlobal.ts +++ b/src/common/utils/defineGlobal.ts @@ -4,9 +4,10 @@ export function defineGlobal(propName: string, descriptor: PropertyDescriptor) { const scope = typeof global !== "undefined" ? global : window; + if (scope.hasOwnProperty(propName)) { - console.info(`Global variable "${propName}" already exists. Skipping.`); return; } + Object.defineProperty(scope, propName, descriptor); } diff --git a/src/extensions/__tests__/extension-loader.test.ts b/src/extensions/__tests__/extension-loader.test.ts index 90aebfaee9..dd09d4614e 100644 --- a/src/extensions/__tests__/extension-loader.test.ts +++ b/src/extensions/__tests__/extension-loader.test.ts @@ -18,6 +18,7 @@ jest.mock( name: "TestExtension", version: "1.0.0", }, + id: manifestPath, absolutePath: "/test/1", manifestPath, isBundled: false, @@ -31,6 +32,7 @@ jest.mock( name: "TestExtension2", version: "2.0.0", }, + id: manifestPath2, absolutePath: "/test/2", manifestPath: manifestPath2, isBundled: false, @@ -54,6 +56,7 @@ jest.mock( name: "TestExtension", version: "1.0.0", }, + id: manifestPath, absolutePath: "/test/1", manifestPath, isBundled: false, @@ -67,6 +70,7 @@ jest.mock( name: "TestExtension3", version: "3.0.0", }, + id: manifestPath3, absolutePath: "/test/3", manifestPath: manifestPath3, isBundled: false, @@ -99,6 +103,7 @@ describe("ExtensionLoader", () => { Map { "manifest/path" => Object { "absolutePath": "/test/1", + "id": "manifest/path", "isBundled": false, "isEnabled": true, "manifest": Object { @@ -109,6 +114,7 @@ describe("ExtensionLoader", () => { }, "manifest/path3" => Object { "absolutePath": "/test/3", + "id": "manifest/path3", "isBundled": false, "isEnabled": true, "manifest": Object { diff --git a/src/extensions/__tests__/lens-extension.test.ts b/src/extensions/__tests__/lens-extension.test.ts index 277a76b410..897c246674 100644 --- a/src/extensions/__tests__/lens-extension.test.ts +++ b/src/extensions/__tests__/lens-extension.test.ts @@ -9,6 +9,7 @@ describe("lens extension", () => { name: "foo-bar", version: "0.1.1" }, + id: "/this/is/fake/package.json", absolutePath: "/absolute/fake/", manifestPath: "/this/is/fake/package.json", isBundled: false, diff --git a/src/extensions/cluster-feature.ts b/src/extensions/cluster-feature.ts index 4cb2c9bf5a..3ee671e6c5 100644 --- a/src/extensions/cluster-feature.ts +++ b/src/extensions/cluster-feature.ts @@ -47,7 +47,7 @@ export abstract class ClusterFeature { abstract async install(cluster: Cluster): Promise; /** - * to be implemented in the derived class, this method is typically called by Lens when a user has indicated that this feature is to be ugraded. The implementation + * to be implemented in the derived class, this method is typically called by Lens when a user has indicated that this feature is to be upgraded. The implementation * of this method should upgrade the kubernetes resources already installed, if relevant to the feature * * @param cluster the cluster that the feature is to be upgraded on @@ -56,7 +56,7 @@ export abstract class ClusterFeature { /** * to be implemented in the derived class, this method is typically called by Lens when a user has indicated that this feature is to be uninstalled. The implementation - * of this method should install kubernetes resources using the kubernetes api (K8sApi) + * of this method should uninstall kubernetes resources using the kubernetes api (K8sApi) * * @param cluster the cluster that the feature is to be uninstalled from */ diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index 452bba4d65..c249496fa6 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -10,6 +10,8 @@ import { extensionsStore } from "./extensions-store"; import type { LensExtensionId, LensExtensionManifest } from "./lens-extension"; export interface InstalledExtension { + id: LensExtensionId; + readonly manifest: LensExtensionManifest; // Absolute path to the non-symlinked source folder, @@ -254,6 +256,7 @@ export class ExtensionDiscovery { const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath); return { + id: installedManifestPath, absolutePath: path.dirname(manifestPath), manifestPath: installedManifestPath, manifest: manifestJson, @@ -273,7 +276,7 @@ export class ExtensionDiscovery { await this.installPackages(); const extensions = bundledExtensions.concat(localExtensions); - return new Map(extensions.map(ext => [ext.manifestPath, ext])); + return new Map(extensions.map(extension => [extension.id, extension])); } /** diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index a73f173e7c..eb49021391 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -61,7 +61,7 @@ export class ExtensionLoader { } addExtension(extension: InstalledExtension) { - this.extensions.set(extension.manifestPath as LensExtensionId, extension); + this.extensions.set(extension.id, extension); } removeInstance(lensExtensionId: LensExtensionId) { @@ -139,8 +139,7 @@ export class ExtensionLoader { ]; this.events.on("remove", (removedExtension: LensRendererExtension) => { - // manifestPath is considered the id - if (removedExtension.manifestPath === extension.manifestPath) { + if (removedExtension.id === extension.id) { removeItems.forEach(remove => { remove(); }); @@ -163,7 +162,7 @@ export class ExtensionLoader { ]; this.events.on("remove", (removedExtension: LensRendererExtension) => { - if (removedExtension.manifestPath === extension.manifestPath) { + if (removedExtension.id === extension.id) { removeItems.forEach(remove => { remove(); }); @@ -191,7 +190,7 @@ export class ExtensionLoader { ]; this.events.on("remove", (removedExtension: LensRendererExtension) => { - if (removedExtension.manifestPath === extension.manifestPath) { + if (removedExtension.id === extension.id) { removeItems.forEach(remove => { remove(); }); diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index 1d16183d75..61dc6c0560 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -16,23 +16,20 @@ export interface LensExtensionManifest { } export class LensExtension { + readonly id: LensExtensionId; readonly manifest: LensExtensionManifest; readonly manifestPath: string; readonly isBundled: boolean; @observable private isEnabled = false; - constructor({ manifest, manifestPath, isBundled }: InstalledExtension) { + constructor({ id, manifest, manifestPath, isBundled }: InstalledExtension) { + this.id = id; this.manifest = manifest; this.manifestPath = manifestPath; this.isBundled = !!isBundled; } - get id(): LensExtensionId { - // This is the symlinked path under node_modules - return this.manifestPath; - } - get name() { return this.manifest.name; } diff --git a/src/extensions/registries/__tests__/page-registry.test.ts b/src/extensions/registries/__tests__/page-registry.test.ts index 4c94274a37..0467ae7ea4 100644 --- a/src/extensions/registries/__tests__/page-registry.test.ts +++ b/src/extensions/registries/__tests__/page-registry.test.ts @@ -11,6 +11,7 @@ describe("getPageUrl", () => { name: "foo-bar", version: "0.1.1" }, + id: "/this/is/fake/package.json", absolutePath: "/absolute/fake/", manifestPath: "/this/is/fake/package.json", isBundled: false, @@ -42,6 +43,7 @@ describe("globalPageRegistry", () => { name: "@acme/foo-bar", version: "0.1.1" }, + id: "/this/is/fake/package.json", absolutePath: "/absolute/fake/", manifestPath: "/this/is/fake/package.json", isBundled: false, diff --git a/src/jest.setup.ts b/src/jest.setup.ts index 7b4732930e..cccef274f0 100644 --- a/src/jest.setup.ts +++ b/src/jest.setup.ts @@ -1,4 +1,3 @@ - import fetchMock from "jest-fetch-mock"; // rewire global.fetch to call 'fetchMock' fetchMock.enableMocks(); diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index 139ebd392c..d87c0bc681 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -1,6 +1,6 @@ import "./add-cluster.scss"; import os from "os"; -import React, { Fragment } from "react"; +import React from "react"; import { observer } from "mobx-react"; import { action, observable, runInAction } from "mobx"; import { remote } from "electron"; @@ -12,7 +12,6 @@ import { DropFileInput, Input } from "../input"; import { AceEditor } from "../ace-editor"; import { Button } from "../button"; import { Icon } from "../icon"; -import { WizardLayout } from "../layout/wizard-layout"; import { kubeConfigDefaultPath, loadConfig, splitConfig, validateConfig, validateKubeConfig } from "../../../common/kube-helpers"; import { ClusterModel, ClusterStore, clusterStore } from "../../../common/cluster-store"; import { workspaceStore } from "../../../common/workspace-store"; @@ -332,14 +331,12 @@ export class AddCluster extends React.Component { }; render() { - const addDisabled = this.selectedContexts.length === 0; + const submitDisabled = this.selectedContexts.length === 0; return ( - Add Clusters}> -

Add Clusters from Kubeconfig

- - {this.renderInfo()} - - + + Add Clusters}> +

Add Clusters from Kubeconfig

+ {this.renderInfo()} {this.renderKubeConfigSource()} {this.renderContextSelector()}
@@ -368,16 +365,16 @@ export class AddCluster extends React.Component {
- - + + ); } } diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx new file mode 100644 index 0000000000..b5a036ad88 --- /dev/null +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -0,0 +1,79 @@ +import '@testing-library/jest-dom/extend-expect'; +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { extensionDiscovery } from "../../../../extensions/extension-discovery"; +import { ConfirmDialog } from "../../confirm-dialog"; +import { Notifications } from "../../notifications"; +import { Extensions } from "../extensions"; + +jest.mock("../../../../extensions/extension-discovery", () => ({ + ...jest.requireActual("../../../../extensions/extension-discovery"), + extensionDiscovery: { + localFolderPath: "/fake/path", + uninstallExtension: jest.fn(() => Promise.resolve()) + } +})); + +jest.mock("../../../../extensions/extension-loader", () => ({ + ...jest.requireActual("../../../../extensions/extension-loader"), + extensionLoader: { + userExtensions: new Map([ + ["extensionId", { + id: "extensionId", + manifest: { + name: "test", + version: "1.2.3" + }, + absolutePath: "/absolute/path", + manifestPath: "/symlinked/path/package.json", + isBundled: false, + isEnabled: true + }] + ]) + } +})); + +jest.mock("../../notifications", () => ({ + ok: jest.fn(), + error: jest.fn(), + info: jest.fn() +})); + +describe("Extensions", () => { + it("disables uninstall and disable buttons while uninstalling", async () => { + render(<>); + + expect(screen.getByText("Disable").closest("button")).not.toBeDisabled(); + expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled(); + + fireEvent.click(screen.getByText("Uninstall")); + + // Approve confirm dialog + fireEvent.click(screen.getByText("Yes")); + + expect(extensionDiscovery.uninstallExtension).toHaveBeenCalledWith("/absolute/path"); + expect(screen.getByText("Disable").closest("button")).toBeDisabled(); + expect(screen.getByText("Uninstall").closest("button")).toBeDisabled(); + }); + + it("displays error notification on uninstall error", () => { + (extensionDiscovery.uninstallExtension as any).mockImplementationOnce(() => + Promise.reject() + ); + render(<>); + + expect(screen.getByText("Disable").closest("button")).not.toBeDisabled(); + expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled(); + + fireEvent.click(screen.getByText("Uninstall")); + + // Approve confirm dialog + fireEvent.click(screen.getByText("Yes")); + + setTimeout(() => { + expect(screen.getByText("Disable").closest("button")).not.toBeDisabled(); + expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled(); + expect(Notifications.error).toHaveBeenCalledTimes(1); + }, 100); + }); +}); diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index 03f34f96ee..327dccee99 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -1,8 +1,8 @@ import { t, Trans } from "@lingui/macro"; import { remote, shell } from "electron"; import fse from "fs-extra"; -import { computed, observable } from "mobx"; -import { observer } from "mobx-react"; +import { computed, observable, reaction } from "mobx"; +import { disposeOnUnmount, observer } from "mobx-react"; import os from "os"; import path from "path"; import React from "react"; @@ -15,6 +15,7 @@ import logger from "../../../main/logger"; import { _i18n } from "../../i18n"; import { prevDefault } from "../../utils"; import { Button } from "../button"; +import { ConfirmDialog } from "../confirm-dialog"; import { Icon } from "../icon"; import { DropFileInput, Input, InputValidator, InputValidators, SearchInput } from "../input"; import { PageLayout } from "../layout/page-layout"; @@ -38,6 +39,12 @@ interface InstallRequestValidated extends InstallRequestPreloaded { tempFile: string; // temp system path to packed extension for unpacking } +interface ExtensionState { + displayName: string; + // Possible states the extension can be + state: "uninstalling"; +} + @observer export class Extensions extends React.Component { private supportedFormats = [".tar", ".tgz"]; @@ -49,17 +56,47 @@ export class Extensions extends React.Component { } }; + @observable + extensionState = observable.map(); + @observable search = ""; @observable installPath = ""; + /** + * Extensions that were removed from extensions but are still in "uninstalling" state + */ + @computed get removedUninstalling() { + return Array.from(this.extensionState.entries()).filter(([id, extension]) => + extension.state === "uninstalling" && !this.extensions.find(extension => extension.id === id) + ).map(([id, extension]) => ({ ...extension, id })); + } + + componentDidMount() { + disposeOnUnmount(this, + reaction(() => this.extensions, (extensions) => { + const removedUninstalling = this.removedUninstalling; + + removedUninstalling.forEach(({ displayName }) => { + Notifications.ok( +

Extension {displayName} successfully uninstalled!

+ ); + }); + + removedUninstalling.forEach(({ id }) => { + this.extensionState.delete(id); + }); + }) + ); + } + @computed get extensions() { const searchText = this.search.toLowerCase(); return Array.from(extensionLoader.userExtensions.values()).filter(ext => { const { name, description } = ext.manifest; return [ name.toLowerCase().includes(searchText), - description.toLowerCase().includes(searchText), - ].some(v => v); + description?.toLowerCase().includes(searchText), + ].some(value => value); }); } @@ -277,15 +314,33 @@ export class Extensions extends React.Component { } } + confirmUninstallExtension = (extension: InstalledExtension) => { + const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); + + ConfirmDialog.open({ + message:

Are you sure you want to uninstall extension {displayName}?

, + labelOk: Yes, + labelCancel: No, + ok: () => this.uninstallExtension(extension) + }); + }; + async uninstallExtension(extension: InstalledExtension) { - const extensionName = extensionDisplayName(extension.manifest.name, extension.manifest.version); + const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); try { + this.extensionState.set(extension.id, { + state: "uninstalling", + displayName + }); + await extensionDiscovery.uninstallExtension(extension.absolutePath); } catch (error) { Notifications.error( -

Uninstalling extension {extensionName} has failed: {error?.message ?? ""}

+

Uninstalling extension {displayName} has failed: {error?.message ?? ""}

); + // Remove uninstall state on uninstall failure + this.extensionState.delete(extension.id); } } @@ -304,12 +359,13 @@ export class Extensions extends React.Component { ); } - return extensions.map(ext => { - const { manifestPath: extId, isEnabled, manifest } = ext; + return extensions.map(extension => { + const { id, isEnabled, manifest } = extension; const { name, description } = manifest; + const isUninstalling = this.extensionState.get(id)?.state === "uninstalling"; return ( -
+
Name: {name} @@ -320,13 +376,17 @@ export class Extensions extends React.Component {
{!isEnabled && ( - + )} {isEnabled && ( - + )} -
diff --git a/src/renderer/components/button/button.scss b/src/renderer/components/button/button.scss index f586a03c5a..b850a3be59 100644 --- a/src/renderer/components/button/button.scss +++ b/src/renderer/components/button/button.scss @@ -12,6 +12,7 @@ flex-shrink: 0; line-height: 1; font-size: $font-size; + user-select: none; &[href] { display: inline-block; diff --git a/src/renderer/components/dock/pod-log-controls.scss b/src/renderer/components/dock/pod-log-controls.scss new file mode 100644 index 0000000000..795d06c67e --- /dev/null +++ b/src/renderer/components/dock/pod-log-controls.scss @@ -0,0 +1,5 @@ +.PodLogControls { + .Select { + min-width: 150px; + } +} \ No newline at end of file diff --git a/src/renderer/components/dock/pod-log-controls.tsx b/src/renderer/components/dock/pod-log-controls.tsx index 17ad8a2ddf..bb4132dc34 100644 --- a/src/renderer/components/dock/pod-log-controls.tsx +++ b/src/renderer/components/dock/pod-log-controls.tsx @@ -1,3 +1,4 @@ +import "./pod-log-controls.scss"; import React from "react"; import { observer } from "mobx-react"; import { IPodLogsData, podLogsStore } from "./pod-logs.store"; @@ -21,10 +22,9 @@ interface Props extends PodLogSearchProps { } export const PodLogControls = observer((props: Props) => { - if (!props.ready) return null; const { tabData, save, reload, tabId, logs } = props; const { selectedContainer, showTimestamps, previous } = tabData; - const rawLogs = podLogsStore.logs.get(tabId); + const rawLogs = podLogsStore.logs.get(tabId) || []; const since = rawLogs.length ? podLogsStore.getTimestamps(rawLogs[0]) : null; const pod = new Pod(tabData.pod); diff --git a/src/renderer/components/dock/pod-log-list.scss b/src/renderer/components/dock/pod-log-list.scss new file mode 100644 index 0000000000..9c14f79fa4 --- /dev/null +++ b/src/renderer/components/dock/pod-log-list.scss @@ -0,0 +1,78 @@ +.PodLogList { + --overlay-bg: #8cc474b8; + --overlay-active-bg: orange; + + // fix for `this.logsElement.scrollTop = this.logsElement.scrollHeight` + // `overflow: overlay` don't allow scroll to the last line + overflow: auto; + + position: relative; + color: $textColorAccent; + background: $logsBackground; + flex-grow: 1; + + .VirtualList { + height: 100%; + + .list { + overflow-x: scroll!important; + + .LogRow { + padding: 2px 16px; + height: 18px; // Must be equal to lineHeight variable in pod-log-list.tsx + font-family: $font-monospace; + font-size: smaller; + white-space: pre; + + &:hover { + background: $logRowHoverBackground; + } + + span { + -webkit-font-smoothing: auto; // Better readability on non-retina screens + } + + span.overlay { + border-radius: 2px; + -webkit-font-smoothing: auto; + background-color: var(--overlay-bg); + + span { + background-color: var(--overlay-bg)!important; // Rewriting inline styles from AnsiUp library + } + + &.active { + background-color: var(--overlay-active-bg); + + span { + background-color: var(--overlay-active-bg)!important; // Rewriting inline styles from AnsiUp library + } + } + } + } + } + } + + &.isLoading { + cursor: wait; + } + + &.isScrollHidden { + .VirtualList .list { + overflow-x: hidden!important; // fixing scroll to bottom issues in PodLogs + } + } + + .JumpToBottom { + position: absolute; + right: 30px; + padding: $unit / 2 $unit * 1.5; + border-radius: $unit * 2; + z-index: 2; + top: 20px; + + .Icon { + --size: $unit * 2; + } + } +} diff --git a/src/renderer/components/dock/pod-log-list.tsx b/src/renderer/components/dock/pod-log-list.tsx new file mode 100644 index 0000000000..e882b38a5b --- /dev/null +++ b/src/renderer/components/dock/pod-log-list.tsx @@ -0,0 +1,224 @@ +import "./pod-log-list.scss"; + +import React from "react"; +import AnsiUp from "ansi_up"; +import DOMPurify from "dompurify"; +import debounce from "lodash/debounce"; +import { Trans } from "@lingui/macro"; +import { action, observable } from "mobx"; +import { observer } from "mobx-react"; +import { Align, ListOnScrollProps } from "react-window"; + +import { searchStore } from "../../../common/search-store"; +import { cssNames } from "../../utils"; +import { Button } from "../button"; +import { Icon } from "../icon"; +import { Spinner } from "../spinner"; +import { VirtualList } from "../virtual-list"; +import { logRange } from "./pod-logs.store"; + +interface Props { + logs: string[] + isLoading: boolean + load: () => void + id: string +} + +const colorConverter = new AnsiUp(); + +@observer +export class PodLogList extends React.Component { + @observable isJumpButtonVisible = false; + @observable isLastLineVisible = true; + + private virtualListDiv = React.createRef(); // A reference for outer container in VirtualList + private virtualListRef = React.createRef(); // A reference for VirtualList component + private lineHeight = 18; // Height of a log line. Should correlate with styles in pod-log-list.scss + + componentDidMount() { + this.scrollToBottom(); + } + + componentDidUpdate(prevProps: Props) { + const { logs, id } = this.props; + if (id != prevProps.id) { + this.isLastLineVisible = true; + return; + } + if (logs == prevProps.logs || !this.virtualListDiv.current) return; + const newLogsLoaded = prevProps.logs.length < logs.length; + const scrolledToBeginning = this.virtualListDiv.current.scrollTop === 0; + const fewLogsLoaded = logs.length < logRange; + if (this.isLastLineVisible) { + this.scrollToBottom(); // Scroll down to keep user watching/reading experience + return; + } + if (scrolledToBeginning && newLogsLoaded) { + this.virtualListDiv.current.scrollTop = (logs.length - prevProps.logs.length) * this.lineHeight; + } + if (fewLogsLoaded) { + this.isJumpButtonVisible = false; + } + if (!logs.length) { + this.isLastLineVisible = false; + } + } + + /** + * Checks if JumpToBottom button should be visible and sets its observable + * @param props Scrolling props from virtual list core + */ + @action + setButtonVisibility = (props: ListOnScrollProps) => { + const offset = 100 * this.lineHeight; + const { scrollHeight } = this.virtualListDiv.current; + const { scrollOffset } = props; + if (scrollHeight - scrollOffset < offset) { + this.isJumpButtonVisible = false; + } else { + this.isJumpButtonVisible = true; + } + }; + + /** + * Checks if last log line considered visible to user, setting its observable + * @param props Scrolling props from virtual list core + */ + @action + setLastLineVisibility = (props: ListOnScrollProps) => { + const { scrollHeight, clientHeight } = this.virtualListDiv.current; + const { scrollOffset, scrollDirection } = props; + if (scrollDirection == "backward") { + this.isLastLineVisible = false; + } else { + if (clientHeight + scrollOffset === scrollHeight) { + this.isLastLineVisible = true; + } + } + }; + + /** + * Check if user scrolled to top and new logs should be loaded + * @param props Scrolling props from virtual list core + */ + checkLoadIntent = (props: ListOnScrollProps) => { + const { scrollOffset } = props; + if (scrollOffset === 0) { + this.props.load(); + } + }; + + @action + scrollToBottom = () => { + if (!this.virtualListDiv.current) return; + this.isJumpButtonVisible = false; + this.virtualListDiv.current.scrollTop = this.virtualListDiv.current.scrollHeight; + }; + + scrollToItem = (index: number, align: Align) => { + this.virtualListRef.current.scrollToItem(index, align); + }; + + onScroll = debounce((props: ListOnScrollProps) => { + if (!this.virtualListDiv.current) return; + this.setButtonVisibility(props); + this.setLastLineVisibility(props); + this.checkLoadIntent(props); + }, 700); // Increasing performance and giving some time for virtual list to settle down + + /** + * A function is called by VirtualList for rendering each of the row + * @param rowIndex index of the log element in logs array + * @returns A react element with a row itself + */ + getLogRow = (rowIndex: number) => { + const { searchQuery, isActiveOverlay } = searchStore; + const item = this.props.logs[rowIndex]; + const contents: React.ReactElement[] = []; + const ansiToHtml = (ansi: string) => DOMPurify.sanitize(colorConverter.ansi_to_html(ansi)); + if (searchQuery) { // If search is enabled, replace keyword with backgrounded + // Case-insensitive search (lowercasing query and keywords in line) + const regex = new RegExp(searchStore.escapeRegex(searchQuery), "gi"); + const matches = item.matchAll(regex); + const modified = item.replace(regex, match => match.toLowerCase()); + // Splitting text line by keyword + const pieces = modified.split(searchQuery.toLowerCase()); + pieces.forEach((piece, index) => { + const active = isActiveOverlay(rowIndex, index); + const lastItem = index === pieces.length - 1; + const overlayValue = matches.next().value; + const overlay = !lastItem + ? + : null; + contents.push( + + + {overlay} + + ); + }); + } + return ( +
+ {contents.length > 1 ? contents : ( + + )} +
+ ); + }; + + render() { + const { logs, isLoading } = this.props; + const isInitLoading = isLoading && !logs.length; + const rowHeights = new Array(logs.length).fill(this.lineHeight); + if (isInitLoading) { + return ; + } + if (!logs.length) { + return ( +
+ There are no logs available for container +
+ ); + } + return ( +
+ + {this.isJumpButtonVisible && ( + + )} +
+ ); + } +} + +interface JumpToBottomProps { + onClick: () => void +} + +const JumpToBottom = ({ onClick }: JumpToBottomProps) => { + return ( + + ); +}; \ No newline at end of file diff --git a/src/renderer/components/dock/pod-logs.scss b/src/renderer/components/dock/pod-logs.scss deleted file mode 100644 index 47909b4fb9..0000000000 --- a/src/renderer/components/dock/pod-logs.scss +++ /dev/null @@ -1,86 +0,0 @@ -.PodLogs { - --overlay-bg: #8cc474b8; - --overlay-active-bg: orange; - - .logs { - overflow: auto; - position: relative; - color: $textColorAccent; - background: $logsBackground; - flex-grow: 1; - - .VirtualList { - height: 100%; - - .list { - .LogRow { - padding: 2px 16px; - height: 18px; // Must be equal to lineHeight variable in pod-logs.scss - font-family: $font-monospace; - font-size: smaller; - white-space: pre; - - &:hover { - background: $logRowHoverBackground; - } - - span { - -webkit-font-smoothing: auto; // Better readability on non-retina screens - } - - span.overlay { - border-radius: 2px; - -webkit-font-smoothing: auto; - background-color: var(--overlay-bg); - - span { - background-color: var(--overlay-bg)!important; // Rewriting inline styles from AnsiUp library - } - - &.active { - background-color: var(--overlay-active-bg); - - span { - background-color: var(--overlay-active-bg)!important; // Rewriting inline styles from AnsiUp library - } - } - } - } - } - } - } - - .jump-to-bottom { - position: absolute; - right: 30px; - padding: $unit / 2 $unit * 1.5; - border-radius: $unit * 2; - opacity: 0; - z-index: 2; - top: 20px; - - &.active { - opacity: 1; - } - - .Icon { - --size: $unit * 2; - } - } - - .PodLogControls { - .Select { - min-width: 150px; - } - } - - .logs .VirtualList .list { - overflow-x: scroll!important; - } - - &.noscroll { - .logs .VirtualList .list { - overflow-x: hidden!important; // fixing scroll to bottom issues in PodLogs - } - } -} \ No newline at end of file diff --git a/src/renderer/components/dock/pod-logs.tsx b/src/renderer/components/dock/pod-logs.tsx index be5a0d5167..71579be112 100644 --- a/src/renderer/components/dock/pod-logs.tsx +++ b/src/renderer/components/dock/pod-logs.tsx @@ -1,65 +1,30 @@ -import "./pod-logs.scss"; import React from "react"; -import AnsiUp from 'ansi_up'; -import DOMPurify from "dompurify"; -import { Trans } from "@lingui/macro"; -import { action, computed, observable, reaction } from "mobx"; +import { computed, observable, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; -import { _i18n } from "../../i18n"; -import { autobind, cssNames } from "../../utils"; -import { Icon } from "../icon"; -import { Spinner } from "../spinner"; + +import { searchStore } from "../../../common/search-store"; +import { autobind } from "../../utils"; import { IDockTab } from "./dock.store"; import { InfoPanel } from "./info-panel"; -import { IPodLogsData, logRange, podLogsStore } from "./pod-logs.store"; -import { Button } from "../button"; import { PodLogControls } from "./pod-log-controls"; -import { VirtualList } from "../virtual-list"; -import { searchStore } from "../../../common/search-store"; -import { ListOnScrollProps } from "react-window"; +import { PodLogList } from "./pod-log-list"; +import { IPodLogsData, podLogsStore } from "./pod-logs.store"; interface Props { className?: string tab: IDockTab } -const lineHeight = 18; // Height of a log line. Should correlate with styles in pod-logs.scss - @observer export class PodLogs extends React.Component { - @observable ready = false; - @observable preloading = false; // Indicator for setting Spinner (loader) at the top of the logs - @observable showJumpToBottom = false; - @observable hideHorizontalScroll = true; // Hiding scrollbar allows to scroll logs down to last element + @observable isLoading = true; - private logsElement = React.createRef(); // A reference for outer container in VirtualList - private virtualListRef = React.createRef(); // A reference for VirtualList component - private lastLineIsShown = true; // used for proper auto-scroll content after refresh - private colorConverter = new AnsiUp(); + private logListElement = React.createRef(); // A reference for VirtualList component componentDidMount() { - disposeOnUnmount(this, [ - reaction(() => this.props.tab.id, async () => { - await this.load(); - this.scrollToBottom(); - }, { fireImmediately: true }), - - // Check if need to show JumpToBottom if new log amount is less than previous one - reaction(() => podLogsStore.logs.get(this.tabId), () => { - const { tabId } = this; - if (podLogsStore.logs.has(tabId) && podLogsStore.logs.get(tabId).length < logRange) { - this.showJumpToBottom = false; - } - }) - ]); - } - - componentDidUpdate() { - // scroll logs only when it's already in the end, - // otherwise it can interrupt reading by jumping after loading new logs update - if (this.logsElement.current && this.lastLineIsShown) { - this.logsElement.current.scrollTop = this.logsElement.current.scrollHeight; - } + disposeOnUnmount(this, + reaction(() => this.props.tab.id, this.reload, { fireImmediately: true }) + ); } get tabData() { @@ -76,33 +41,16 @@ export class PodLogs extends React.Component { } load = async () => { - this.ready = false; + this.isLoading = true; await podLogsStore.load(this.tabId); - this.ready = true; + this.isLoading = false; }; reload = async () => { podLogsStore.clearLogs(this.tabId); - this.lastLineIsShown = true; await this.load(); }; - /** - * Function loads more logs (usually after user scrolls to top) and sets proper - * scrolling position - */ - loadMore = async () => { - const lines = podLogsStore.lines; - if (lines < logRange) return; - this.preloading = true; - await podLogsStore.load(this.tabId); - this.preloading = false; - if (podLogsStore.lines > lines) { - // Set scroll position back to place where preloading started - this.logsElement.current.scrollTop = (podLogsStore.lines - lines) * lineHeight; - } - }; - /** * A function for various actions after search is happened * @param query {string} A text from search field @@ -118,9 +66,9 @@ export class PodLogs extends React.Component { @autobind() toOverlay() { const { activeOverlayLine } = searchStore; - if (!this.virtualListRef.current || activeOverlayLine === undefined) return; + if (!this.logListElement.current || activeOverlayLine === undefined) return; // Scroll vertically - this.virtualListRef.current.scrollToItem(activeOverlayLine, "center"); + this.logListElement.current.scrollToItem(activeOverlayLine, "center"); // Scroll horizontally in timeout since virtual list need some time to prepare its contents setTimeout(() => { const overlay = document.querySelector(".PodLogs .list span.active"); @@ -145,139 +93,10 @@ export class PodLogs extends React.Component { return logs; } - onScroll = (props: ListOnScrollProps) => { - if (!this.logsElement.current) return; - const toBottomOffset = 100 * lineHeight; // 100 lines * 18px (height of each line) - const { scrollHeight, clientHeight } = this.logsElement.current; - const { scrollDirection, scrollOffset, scrollUpdateWasRequested } = props; - if (scrollDirection == "forward") { - if (scrollHeight - scrollOffset < toBottomOffset) { - this.showJumpToBottom = false; - } - if (clientHeight + scrollOffset === scrollHeight) { - this.lastLineIsShown = true; - } - } else { - this.lastLineIsShown = false; - // Trigger loading only if scrolled by user - if (scrollOffset === 0 && !scrollUpdateWasRequested) { - this.loadMore(); - } - if (scrollHeight - scrollOffset > toBottomOffset) { - this.showJumpToBottom = true; - } - } - }; - - @action - scrollToBottom = () => { - if (!this.virtualListRef.current) return; - this.hideHorizontalScroll = true; - this.virtualListRef.current.scrollToItem(this.logs.length, "end"); - this.showJumpToBottom = false; - // Showing horizontal scrollbar after VirtualList settles down - setTimeout(() => this.hideHorizontalScroll = false, 500); - }; - - /** - * A function is called by VirtualList for rendering each of the row - * @param rowIndex {Number} index of the log element in logs array - * @returns A react element with a row itself - */ - getLogRow = (item: string, rowIndex: number) => { - const { searchQuery, isActiveOverlay } = searchStore; - const contents: React.ReactElement[] = []; - const ansiToHtml = (ansi: string) => DOMPurify.sanitize(this.colorConverter.ansi_to_html(ansi)); - if (searchQuery) { // If search is enabled, replace keyword with backgrounded - // Case-insensitive search (lowercasing query and keywords in line) - const regex = new RegExp(searchStore.escapeRegex(searchQuery), "gi"); - const matches = item.matchAll(regex); - const modified = item.replace(regex, match => match.toLowerCase()); - // Splitting text line by keyword - const pieces = modified.split(searchQuery.toLowerCase()); - pieces.forEach((piece, index) => { - const active = isActiveOverlay(rowIndex, index); - const lastItem = index === pieces.length - 1; - const overlayValue = matches.next().value; - const overlay = !lastItem ? - : - null; - contents.push( - - - {overlay} - - ); - }); - } - return ( -
- {contents.length > 1 ? contents : ( - - )} -
- ); - }; - - renderJumpToBottom() { - if (!this.logsElement) return null; - return ( - - ); - } - - renderLogs() { - // Generating equal heights for each row with ability to do multyrow logs in future - // e. g. for wrapping logs feature - const rowHeights = new Array(this.logs.length).fill(lineHeight); - if (!this.ready) { - return ; - } - if (!this.logs.length) { - return ( -
- There are no logs available for container. -
- ); - } - return ( - <> - {this.preloading && ( -
- -
- )} - - - ); - } - render() { - const { className } = this.props; const controls = ( { /> ); return ( -
+
-
- {this.renderJumpToBottom()} - {this.renderLogs()} -
+
); } diff --git a/static/RELEASE_NOTES.md b/static/RELEASE_NOTES.md index 3bbff0b8a5..fe22568bda 100644 --- a/static/RELEASE_NOTES.md +++ b/static/RELEASE_NOTES.md @@ -2,7 +2,7 @@ Here you can find description of changes we've built into each release. While we try our best to make each upgrade automatic and as smooth as possible, there may be some cases where you might need to do something to ensure the application works smoothly. So please read through the release highlights! -## 4.0.0-beta.4 (current version) +## 4.0.0-rc.1 (current version) - Extension API - Improved pod logs @@ -12,14 +12,21 @@ Here you can find description of changes we've built into each release. While we - Add LoadBalancer information to Ingress view - Add search by ip to Pod view - Move tracker to an extension -- Add support page (as an extension) - Ability to restart deployment +- Add stateful set scale slider - Status bar visual fixes -- Fix proxy upgrade socket timeouts -- Fix UI staleness after network issues - Add +/- buttons in scale deployment popup screen - Update chart details when selecting another chart - Use latest alpine version (3.12) for shell sessions +- Open last active cluster after switching workspaces +- Replace deprecated stable helm repository with bitnami +- Catch errors return error response when fetching chart or chart values fails +- Update EULA url +- Change add-cluster to single column layout +- Replace cluster warning event polling with watches +- Fix pod usage metrics on Kubernetes >=1.19 +- Fix proxy upgrade socket timeouts +- Fix UI staleness after network issues - Fix errors on app quit - Fix kube-auth-proxy to accept only target cluster hostname diff --git a/yarn.lock b/yarn.lock index 6e39a26a83..caaf0bd5d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9879,10 +9879,10 @@ mobx@^5.15.4: resolved "https://registry.yarnpkg.com/mobx/-/mobx-5.15.4.tgz#9da1a84e97ba624622f4e55a0bf3300fb931c2ab" integrity sha512-xRFJxSU2Im3nrGCdjSuOTFmxVDGeqOHL+TyADCGbT0k4HHqGmx5u2yaHNryvoORpI4DfbzjJ5jPmuv+d7sioFw== -mobx@^5.15.5: - version "5.15.5" - resolved "https://registry.yarnpkg.com/mobx/-/mobx-5.15.5.tgz#69715dc8662f64d153309bfe95169b8df4b4be4b" - integrity sha512-hzk17T+/IIYLPWClRcfoA6Q5aZhFpDCr1oh8RZzu+esWP77IX/lS0V/Ee1Np+aOPKFfbSInF0reHH0L/aFfSrw== +mobx@^5.15.7: + version "5.15.7" + resolved "https://registry.yarnpkg.com/mobx/-/mobx-5.15.7.tgz#b9a5f2b6251f5d96980d13c78e9b5d8d4ce22665" + integrity sha512-wyM3FghTkhmC+hQjyPGGFdpehrcX1KOXsDuERhfK2YbJemkUhEB+6wzEN639T21onxlfYBmriA1PFnvxTUhcKw== mock-fs@^4.12.0: version "4.12.0"