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:
commit
800bc5b821
@ -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:
|
||||

|
||||
|
||||
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)
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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`
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||

|
||||
|
||||
## 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!
|
||||
BIN
docs/helm/images/helm-charts.png
Normal file
BIN
docs/helm/images/helm-charts.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
10
package.json
10
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",
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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]));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
import fetchMock from "jest-fetch-mock";
|
||||
// rewire global.fetch to call 'fetchMock'
|
||||
fetchMock.enableMocks();
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
font-size: $font-size;
|
||||
user-select: none;
|
||||
|
||||
&[href] {
|
||||
display: inline-block;
|
||||
|
||||
5
src/renderer/components/dock/pod-log-controls.scss
Normal file
5
src/renderer/components/dock/pod-log-controls.scss
Normal file
@ -0,0 +1,5 @@
|
||||
.PodLogControls {
|
||||
.Select {
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
78
src/renderer/components/dock/pod-log-list.scss
Normal file
78
src/renderer/components/dock/pod-log-list.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
224
src/renderer/components/dock/pod-log-list.tsx
Normal file
224
src/renderer/components/dock/pod-log-list.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user