1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Merge branch 'master' into fix/virtual-list-item-types

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>
This commit is contained in:
Alex Andreev 2020-11-27 16:14:31 +03:00
commit 800bc5b821
28 changed files with 704 additions and 414 deletions

View File

@ -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 `<body>` element gets a "theme-light" class, or: `<body class="theme-light">`. 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 `<body>` 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 (
<div className="SupportPage">
<h1>Active theme is {Theme.getActiveTheme().name}</h1>
</div>
);
}
}
```
`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)

View File

@ -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 <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps |
[minikube](https://github.com/lensapp/lens-extension-samples/tree/master/minikube-sample) | LensMainExtension <br> Store.clusterStore <br> Store.workspaceStore |
[styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps |
[styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps |
[styling-sass-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-sass-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps |
[custom-resource-page](https://github.com/lensapp/lens-extension-samples/tree/master/custom-resource-page) | LensRendererExtension <br> K8sApi.KubeApi <br> K8sApi.KubeObjectStore <br> Component.KubeObjectListLayout <br> Component.KubeObjectDetailsProps <br> Component.IconProps |

View File

@ -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 (
<span>
Enable an example feature.
</span>
)
}
},
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<void>;
abstract upgrade(cluster: Cluster): Promise<void>;
abstract uninstall(cluster: Cluster): Promise<void>;
abstract updateStatus(cluster: Cluster): Promise<ClusterFeatureStatus>;
```
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<void> {
super.applyResources(cluster, path.join(__dirname, "../resources/"));
}
async upgrade(cluster: Store.Cluster): Promise<void> {
return this.install(cluster);
}
async updateStatus(cluster: Store.Cluster): Promise<ClusterFeature.FeatureStatus> {
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<void> {
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 (
<span>
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 <a href="https://github.com/lensapp/lens/tree/master/extensions/lens-metrics/resources" target="_blank">here</a>.
</span>
)
}
},
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<void>;
abstract upgrade(cluster: Cluster): Promise<void>;
abstract uninstall(cluster: Cluster): Promise<void>;
abstract updateStatus(cluster: Cluster): Promise<ClusterFeatureStatus>;
```
### `appPreferences`

View File

@ -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!

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -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",

View File

@ -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", () => {

View File

@ -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);
}

View File

@ -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 {

View File

@ -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,

View File

@ -47,7 +47,7 @@ export abstract class ClusterFeature {
abstract async install(cluster: Cluster): Promise<void>;
/**
* 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
*/

View File

@ -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]));
}
/**

View File

@ -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();
});

View File

@ -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;
}

View File

@ -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,

View File

@ -1,4 +1,3 @@
import fetchMock from "jest-fetch-mock";
// rewire global.fetch to call 'fetchMock'
fetchMock.enableMocks();

View File

@ -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 (
<PageLayout className="AddClusters" header={<h2>Add Clusters</h2>}>
<h2>Add Clusters from Kubeconfig</h2>
{this.renderInfo()}
<DropFileInput onDropFiles={this.onDropKubeConfig}>
<DropFileInput onDropFiles={this.onDropKubeConfig}>
<PageLayout className="AddClusters" header={<h2>Add Clusters</h2>}>
<h2>Add Clusters from Kubeconfig</h2>
{this.renderInfo()}
{this.renderKubeConfigSource()}
{this.renderContextSelector()}
<div className="cluster-settings">
@ -368,16 +365,16 @@ export class AddCluster extends React.Component {
<div className="actions-panel">
<Button
primary
disabled={addDisabled}
disabled={submitDisabled}
label={this.selectedContexts.length < 2 ? <Trans>Add cluster</Trans> : <Trans>Add clusters</Trans>}
onClick={this.addClusters}
waiting={this.isWaiting}
tooltip={addDisabled ? _i18n._("Select at least one cluster to add.") : undefined}
tooltip={submitDisabled ? _i18n._("Select at least one cluster to add.") : undefined}
tooltipOverrideDisabled
/>
</div>
</DropFileInput>
</PageLayout>
</PageLayout>
</DropFileInput>
);
}
}

View File

@ -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(<><Extensions /><ConfirmDialog/></>);
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(<><Extensions /><ConfirmDialog/></>);
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);
});
});

View File

@ -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<string, ExtensionState>();
@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(
<p>Extension <b>{displayName}</b> successfully uninstalled!</p>
);
});
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: <p>Are you sure you want to uninstall extension <b>{displayName}</b>?</p>,
labelOk: <Trans>Yes</Trans>,
labelCancel: <Trans>No</Trans>,
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(
<p>Uninstalling extension <b>{extensionName}</b> has failed: <em>{error?.message ?? ""}</em></p>
<p>Uninstalling extension <b>{displayName}</b> has failed: <em>{error?.message ?? ""}</em></p>
);
// 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 (
<div key={extId} className="extension flex gaps align-center">
<div key={id} className="extension flex gaps align-center">
<div className="box grow">
<div className="name">
Name: <code className="name">{name}</code>
@ -320,13 +376,17 @@ export class Extensions extends React.Component {
</div>
<div className="actions">
{!isEnabled && (
<Button plain active onClick={() => ext.isEnabled = true}>Enable</Button>
<Button plain active disabled={isUninstalling} onClick={() => {
extension.isEnabled = true;
}}>Enable</Button>
)}
{isEnabled && (
<Button accent onClick={() => ext.isEnabled = false}>Disable</Button>
<Button accent disabled={isUninstalling} onClick={() => {
extension.isEnabled = false;
}}>Disable</Button>
)}
<Button plain active onClick={() => {
this.uninstallExtension(ext);
<Button plain active disabled={isUninstalling} waiting={isUninstalling} onClick={() => {
this.confirmUninstallExtension(extension);
}}>Uninstall</Button>
</div>
</div>

View File

@ -12,6 +12,7 @@
flex-shrink: 0;
line-height: 1;
font-size: $font-size;
user-select: none;
&[href] {
display: inline-block;

View File

@ -0,0 +1,5 @@
.PodLogControls {
.Select {
min-width: 150px;
}
}

View File

@ -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);

View File

@ -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;
}
}
}

View File

@ -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<Props> {
@observable isJumpButtonVisible = false;
@observable isLastLineVisible = true;
private virtualListDiv = React.createRef<HTMLDivElement>(); // A reference for outer container in VirtualList
private virtualListRef = React.createRef<VirtualList>(); // 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 <span>
// 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
? <span
className={cssNames("overlay", { active })}
dangerouslySetInnerHTML={{ __html: ansiToHtml(overlayValue) }}
/>
: null;
contents.push(
<React.Fragment key={piece + index}>
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(piece) }} />
{overlay}
</React.Fragment>
);
});
}
return (
<div className={cssNames("LogRow")}>
{contents.length > 1 ? contents : (
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(item) }} />
)}
</div>
);
};
render() {
const { logs, isLoading } = this.props;
const isInitLoading = isLoading && !logs.length;
const rowHeights = new Array(logs.length).fill(this.lineHeight);
if (isInitLoading) {
return <Spinner center/>;
}
if (!logs.length) {
return (
<div className="PodLogList flex box grow align-center justify-center">
<Trans>There are no logs available for container</Trans>
</div>
);
}
return (
<div className={cssNames("PodLogList flex", { isLoading })}>
<VirtualList
items={logs}
rowHeights={rowHeights}
getRow={this.getLogRow}
onScroll={this.onScroll}
outerRef={this.virtualListDiv}
ref={this.virtualListRef}
className="box grow"
/>
{this.isJumpButtonVisible && (
<JumpToBottom onClick={this.scrollToBottom} />
)}
</div>
);
}
}
interface JumpToBottomProps {
onClick: () => void
}
const JumpToBottom = ({ onClick }: JumpToBottomProps) => {
return (
<Button
primary
className="JumpToBottom flex gaps"
onClick={evt => {
evt.currentTarget.blur();
onClick();
}}
>
<Trans>Jump to bottom</Trans>
<Icon material="expand_more" />
</Button>
);
};

View File

@ -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
}
}
}

View File

@ -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<Props> {
@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<HTMLDivElement>(); // A reference for outer container in VirtualList
private virtualListRef = React.createRef<VirtualList>(); // A reference for VirtualList component
private lastLineIsShown = true; // used for proper auto-scroll content after refresh
private colorConverter = new AnsiUp();
private logListElement = React.createRef<PodLogList>(); // 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<Props> {
}
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<Props> {
@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<Props> {
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 <span>
// 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 ?
<span
className={cssNames("overlay", { active })}
dangerouslySetInnerHTML={{ __html: ansiToHtml(overlayValue) }}
/> :
null;
contents.push(
<React.Fragment key={piece + index}>
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(piece) }} />
{overlay}
</React.Fragment>
);
});
}
return (
<div className={cssNames("LogRow")}>
{contents.length > 1 ? contents : (
<span dangerouslySetInnerHTML={{ __html: ansiToHtml(item) }} />
)}
</div>
);
};
renderJumpToBottom() {
if (!this.logsElement) return null;
return (
<Button
primary
className={cssNames("jump-to-bottom flex gaps", {active: this.showJumpToBottom})}
onClick={evt => {
evt.currentTarget.blur();
this.scrollToBottom();
}}
>
<Trans>Jump to bottom</Trans>
<Icon material="expand_more" />
</Button>
);
}
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 <Spinner center/>;
}
if (!this.logs.length) {
return (
<div className="flex box grow align-center justify-center">
<Trans>There are no logs available for container.</Trans>
</div>
);
}
return (
<>
{this.preloading && (
<div className="flex justify-center">
<Spinner center />
</div>
)}
<VirtualList
items={this.logs}
rowHeights={rowHeights}
getRow={this.getLogRow}
onScroll={this.onScroll}
outerRef={this.logsElement}
ref={this.virtualListRef}
className="box grow"
/>
</>
);
}
render() {
const { className } = this.props;
const controls = (
<PodLogControls
ready={this.ready}
ready={!this.isLoading}
tabId={this.tabId}
tabData={this.tabData}
logs={this.logs}
@ -289,17 +108,20 @@ export class PodLogs extends React.Component<Props> {
/>
);
return (
<div className={cssNames("PodLogs flex column", className, { noscroll: this.hideHorizontalScroll })}>
<div className="PodLogs flex column">
<InfoPanel
tabId={this.props.tab.id}
controls={controls}
showSubmitClose={false}
showButtons={false}
/>
<div className="logs flex">
{this.renderJumpToBottom()}
{this.renderLogs()}
</div>
<PodLogList
id={this.tabId}
isLoading={this.isLoading}
logs={this.logs}
load={this.load}
ref={this.logListElement}
/>
</div>
);
}

View File

@ -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

View File

@ -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"