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

Merge branch 'master' into feature/lens-proxy-tls

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen 2021-02-11 10:33:06 +02:00
commit 17477c4fd7
303 changed files with 14255 additions and 3211 deletions

View File

@ -58,6 +58,7 @@ jobs:
- script: make test-extensions - script: make test-extensions
displayName: Run In-tree Extension tests displayName: Run In-tree Extension tests
- bash: | - bash: |
set -e
rm -rf extensions/telemetry rm -rf extensions/telemetry
make integration-win make integration-win
git checkout extensions/telemetry git checkout extensions/telemetry
@ -102,6 +103,7 @@ jobs:
- script: make test-extensions - script: make test-extensions
displayName: Run In-tree Extension tests displayName: Run In-tree Extension tests
- bash: | - bash: |
set -e
rm -rf extensions/telemetry rm -rf extensions/telemetry
make integration-mac make integration-mac
git checkout extensions/telemetry git checkout extensions/telemetry
@ -159,6 +161,7 @@ jobs:
sudo chown -R $USER $HOME/.kube $HOME/.minikube sudo chown -R $USER $HOME/.kube $HOME/.minikube
displayName: Install integration test dependencies displayName: Install integration test dependencies
- bash: | - bash: |
set -e
rm -rf extensions/telemetry rm -rf extensions/telemetry
xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' make integration-linux xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' make integration-linux
git checkout extensions/telemetry git checkout extensions/telemetry

17
.dependabot/config.yml Normal file
View File

@ -0,0 +1,17 @@
# See https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/configuration-options-for-dependency-updates
# for config options
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 4
reviewers:
- "lensapp/lens-maintainers"
labels:
- "dependencies"
versioning-strategy:
lockfile-only: false
increase: true

View File

@ -46,6 +46,8 @@ module.exports = {
"avoidEscape": true, "avoidEscape": true,
"allowTemplateLiterals": true, "allowTemplateLiterals": true,
}], }],
"linebreak-style": ["error", "unix"],
"eol-last": ["error", "always"],
"semi": ["error", "always"], "semi": ["error", "always"],
"object-shorthand": "error", "object-shorthand": "error",
"prefer-template": "error", "prefer-template": "error",
@ -101,6 +103,8 @@ module.exports = {
}], }],
"semi": "off", "semi": "off",
"@typescript-eslint/semi": ["error"], "@typescript-eslint/semi": ["error"],
"linebreak-style": ["error", "unix"],
"eol-last": ["error", "always"],
"object-shorthand": "error", "object-shorthand": "error",
"prefer-template": "error", "prefer-template": "error",
"template-curly-spacing": "error", "template-curly-spacing": "error",
@ -162,6 +166,8 @@ module.exports = {
}], }],
"semi": "off", "semi": "off",
"@typescript-eslint/semi": ["error"], "@typescript-eslint/semi": ["error"],
"linebreak-style": ["error", "unix"],
"eol-last": ["error", "always"],
"object-shorthand": "error", "object-shorthand": "error",
"prefer-template": "error", "prefer-template": "error",
"template-curly-spacing": "error", "template-curly-spacing": "error",

30
.github/release-drafter.yml vendored Normal file
View File

@ -0,0 +1,30 @@
exclude-labels:
- 'skip-changelog'
categories:
- title: '🚀 Features'
labels:
- 'enhancement'
- title: '🐛 Bug Fixes'
labels:
- 'bug'
- title: '🧰 Maintenance'
labels:
- 'chore'
- 'area/ci'
- 'area/tests'
- 'dependencies'
template: |
## Changes since $PREVIOUS_TAG
$CHANGES
### Download
- Lens v$RESOLVED_VERSION - Linux
- [AppImage](https://github.com/lensapp/lens/releases/download/v$RESOLVED_VERSION/Lens-$RESOLVED_VERSION.x86_64.AppImage)
- [DEB](https://github.com/lensapp/lens/releases/download/v$RESOLVED_VERSION/Lens-$RESOLVED_VERSION.amd64.deb)
- [RPM](https://github.com/lensapp/lens/releases/download/v$RESOLVED_VERSION/Lens-$RESOLVED_VERSION.x86_64.rpm)
- [Snapcraft](https://snapcraft.io/kontena-lens)
- [Lens v$RESOLVED_VERSION - MacOS](https://github.com/lensapp/lens/releases/download/v$RESOLVED_VERSION/Lens-$RESOLVED_VERSION.dmg)
- [Lens v$RESOLVED_VERSION - Windows](https://github.com/lensapp/lens/releases/download/v$RESOLVED_VERSION/Lens-Setup-$RESOLVED_VERSION.exe)

16
.github/workflows/release-drafter.yml vendored Normal file
View File

@ -0,0 +1,16 @@
name: Release Drafter
on:
push:
# branches to consider in the event; optional, defaults to all
branches:
- master
jobs:
update_release_draft:
runs-on: ubuntu-latest
steps:
# Drafts your next Release notes as Pull Requests are merged into "master"
- uses: release-drafter/release-drafter@v5
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}

View File

@ -12,7 +12,7 @@ endif
binaries/client: binaries/client:
yarn download-bins yarn download-bins
node_modules: node_modules: yarn.lock
yarn install --frozen-lockfile yarn install --frozen-lockfile
yarn check --verify-tree --integrity yarn check --verify-tree --integrity

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 23 KiB

BIN
build/icons/512x512@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

@ -43,6 +43,7 @@ You can use theme-based CSS Variables to style an extension according to the act
## Button Colors ## Button Colors
- `--buttonPrimaryBackground`: button background color for primary actions. - `--buttonPrimaryBackground`: button background color for primary actions.
- `--buttonDefaultBackground`: default button background color. - `--buttonDefaultBackground`: default button background color.
- `--buttonLightBackground`: light button background color.
- `--buttonAccentBackground`: accent button background color. - `--buttonAccentBackground`: accent button background color.
- `--buttonDisabledBackground`: disabled button background color. - `--buttonDisabledBackground`: disabled button background color.

View File

@ -40,7 +40,7 @@ This extension can register custom app menus that will be displayed on OS native
Example: Example:
``` typescript ```typescript
import { LensMainExtension, windowManager } from "@k8slens/extensions" import { LensMainExtension, windowManager } from "@k8slens/extensions"
export default class ExampleMainExtension extends LensMainExtension { export default class ExampleMainExtension extends LensMainExtension {
@ -92,7 +92,7 @@ export default class ExampleMainExtension extends LensRendererExtension {
This extension can register custom global pages (views) to Lens's main window. The global page is a full-screen page that hides all other content from a window. This extension can register custom global pages (views) to Lens's main window. The global page is a full-screen page that hides all other content from a window.
``` typescript ```typescript
import React from "react" import React from "react"
import { Component, LensRendererExtension } from "@k8slens/extensions" import { Component, LensRendererExtension } from "@k8slens/extensions"
import { ExamplePage } from "./src/example-page" import { ExamplePage } from "./src/example-page"
@ -123,7 +123,7 @@ export default class ExampleRendererExtension extends LensRendererExtension {
This extension can register custom app preferences. It is responsible for storing a state for custom preferences. This extension can register custom app preferences. It is responsible for storing a state for custom preferences.
``` typescript ```typescript
import React from "react" import React from "react"
import { LensRendererExtension } from "@k8slens/extensions" import { LensRendererExtension } from "@k8slens/extensions"
import { myCustomPreferencesStore } from "./src/my-custom-preferences-store" import { myCustomPreferencesStore } from "./src/my-custom-preferences-store"
@ -147,7 +147,7 @@ export default class ExampleRendererExtension extends LensRendererExtension {
This extension can register custom cluster pages. These pages are visible in a cluster menu when a cluster is opened. This extension can register custom cluster pages. These pages are visible in a cluster menu when a cluster is opened.
``` typescript ```typescript
import React from "react" import React from "react"
import { LensRendererExtension } from "@k8slens/extensions"; import { LensRendererExtension } from "@k8slens/extensions";
import { ExampleIcon, ExamplePage } from "./src/page" import { ExampleIcon, ExamplePage } from "./src/page"
@ -180,7 +180,7 @@ export default class ExampleExtension extends LensRendererExtension {
This extension can register installable features for a cluster. These features are visible in the "Cluster Settings" page. This extension can register installable features for a cluster. These features are visible in the "Cluster Settings" page.
``` typescript ```typescript
import React from "react" import React from "react"
import { LensRendererExtension } from "@k8slens/extensions" import { LensRendererExtension } from "@k8slens/extensions"
import { MyCustomFeature } from "./src/my-custom-feature" import { MyCustomFeature } from "./src/my-custom-feature"
@ -209,18 +209,20 @@ export default class ExampleExtension extends LensRendererExtension {
This extension can register custom icons and text to a status bar area. This extension can register custom icons and text to a status bar area.
``` typescript ```typescript
import React from "react"; import React from "react";
import { Component, LensRendererExtension, Navigation } from "@k8slens/extensions"; import { Component, LensRendererExtension, Navigation } from "@k8slens/extensions";
export default class ExampleExtension extends LensRendererExtension { export default class ExampleExtension extends LensRendererExtension {
statusBarItems = [ statusBarItems = [
{ {
item: ( components: {
<div className="flex align-center gaps hover-highlight" onClick={() => this.navigate("/example-page")} > Item: (
<Component.Icon material="favorite" /> <div className="flex align-center gaps hover-highlight" onClick={() => this.navigate("/example-page")} >
</div> <Component.Icon material="favorite" />
) </div>
)
}
} }
] ]
} }
@ -231,7 +233,7 @@ export default class ExampleExtension extends LensRendererExtension {
This extension can register custom menu items (actions) for specified Kubernetes kinds/apiVersions. This extension can register custom menu items (actions) for specified Kubernetes kinds/apiVersions.
``` typescript ```typescript
import React from "react" import React from "react"
import { LensRendererExtension } from "@k8slens/extensions"; import { LensRendererExtension } from "@k8slens/extensions";
import { CustomMenuItem, CustomMenuItemProps } from "./src/custom-menu-item" import { CustomMenuItem, CustomMenuItemProps } from "./src/custom-menu-item"
@ -254,7 +256,7 @@ export default class ExampleExtension extends LensRendererExtension {
This extension can register custom details (content) for specified Kubernetes kinds/apiVersions. This extension can register custom details (content) for specified Kubernetes kinds/apiVersions.
``` typescript ```typescript
import React from "react" import React from "react"
import { LensRendererExtension } from "@k8slens/extensions"; import { LensRendererExtension } from "@k8slens/extensions";
import { CustomKindDetails, CustomKindDetailsProps } from "./src/custom-kind-details" import { CustomKindDetails, CustomKindDetailsProps } from "./src/custom-kind-details"

View File

@ -42,7 +42,7 @@ Next, you'll try changing the way the new menu item appears in the UI. You'll ch
Open `my-first-lens-ext/renderer.tsx` and change the value of `title` from `"Hello World"` to `"Hello Lens"`: Open `my-first-lens-ext/renderer.tsx` and change the value of `title` from `"Hello World"` to `"Hello Lens"`:
```tsx ```typescript
clusterPageMenus = [ clusterPageMenus = [
{ {
target: { pageId: "hello" }, target: { pageId: "hello" },

View File

@ -22,7 +22,7 @@ All UI elements are based on React components.
To create a renderer extension, extend the `LensRendererExtension` class: To create a renderer extension, extend the `LensRendererExtension` class:
``` typescript ```typescript
import { LensRendererExtension } from "@k8slens/extensions"; import { LensRendererExtension } from "@k8slens/extensions";
export default class ExampleExtensionMain extends LensRendererExtension { export default class ExampleExtensionMain extends LensRendererExtension {
@ -52,7 +52,7 @@ Cluster pages appear in the cluster dashboard. Use cluster pages to display info
Add a cluster page definition to a `LensRendererExtension` subclass with the following example: Add a cluster page definition to a `LensRendererExtension` subclass with the following example:
``` typescript ```typescript
import { LensRendererExtension } from "@k8slens/extensions"; import { LensRendererExtension } from "@k8slens/extensions";
import { ExampleIcon, ExamplePage } from "./page" import { ExampleIcon, ExamplePage } from "./page"
import React from "react" import React from "react"
@ -77,7 +77,7 @@ export default class ExampleExtension extends LensRendererExtension {
`ExamplePage` in the example above can be defined in `page.tsx`: `ExamplePage` in the example above can be defined in `page.tsx`:
``` typescript ```typescript
import { LensRendererExtension } from "@k8slens/extensions"; import { LensRendererExtension } from "@k8slens/extensions";
import React from "react" import React from "react"
@ -102,7 +102,7 @@ The above example shows how to create a cluster page, but not how to make that p
By expanding on the above example, you can add a cluster page menu item to the `ExampleExtension` definition: By expanding on the above example, you can add a cluster page menu item to the `ExampleExtension` definition:
``` typescript ```typescript
import { LensRendererExtension } from "@k8slens/extensions"; import { LensRendererExtension } from "@k8slens/extensions";
import { ExampleIcon, ExamplePage } from "./page" import { ExampleIcon, ExamplePage } from "./page"
import React from "react" import React from "react"
@ -140,7 +140,7 @@ The above example creates a menu item that reads **Hello World**. When users cli
This example requires the definition of another React-based component, `ExampleIcon`, which has been added to `page.tsx`, as follows: This example requires the definition of another React-based component, `ExampleIcon`, which has been added to `page.tsx`, as follows:
``` typescript ```typescript
import { LensRendererExtension, Component } from "@k8slens/extensions"; import { LensRendererExtension, Component } from "@k8slens/extensions";
import React from "react" import React from "react"
@ -167,7 +167,7 @@ Lens includes various built-in components available for extension developers to
`clusterPageMenus` can also be used to define sub menu items, so that you can create groups of cluster pages. The following example groups two sub menu items under one parent menu item: `clusterPageMenus` can also be used to define sub menu items, so that you can create groups of cluster pages. The following example groups two sub menu items under one parent menu item:
``` typescript ```typescript
import { LensRendererExtension } from "@k8slens/extensions"; import { LensRendererExtension } from "@k8slens/extensions";
import { ExampleIcon, ExamplePage } from "./page" import { ExampleIcon, ExamplePage } from "./page"
import React from "react" import React from "react"
@ -216,12 +216,20 @@ export default class ExampleExtension extends LensRendererExtension {
} }
``` ```
The above defines two cluster pages and three cluster page menu objects. The three cluster page menu objects include one parent menu item and two sub menu items. Parent items require an `id` value, whereas sub items require a `parentId` value. The value of the sub item `parentId` will match the value of the corresponding parent item `id`. Parent items don't require a `target` value. Assign values to the remaining properties as explained above. The above defines two cluster pages and three cluster page menu objects.
The cluster page definitions are straightforward.
The three cluster page menu objects include one parent menu item and two sub menu items.
The first cluster page menu object defines the parent of a foldout submenu.
Setting the `id` field in a cluster page menu definition implies that it is defining a foldout submenu.
Also note that the `target` field is not specified (it is ignored if the `id` field is specified).
This cluster page menu object specifies the `title` and `components` fields, which are used in displaying the menu item in the cluster dashboard sidebar.
Initially the submenu is hidden.
Activating this menu item toggles on and off the appearance of the submenu below it.
The remaining two cluster page menu objects define the contents of the submenu.
A cluster page menu object is defined to be a submenu item by setting the `parentId` field to the id of the parent of a foldout submenu, `"example"` in this case.
This is what the example will look like, including how the menu item will appear in the secondary left nav: This is what the example will look like, including how the menu item will appear in the secondary left nav:
![clusterPageMenus](images/clusterpagemenus.png)
### `globalPages` ### `globalPages`
Global pages are independent of the cluster dashboard and can fill the entire Lens UI. Their primary use is to display information and provide functionality across clusters, including customized data and functionality unique to your extension. Global pages are independent of the cluster dashboard and can fill the entire Lens UI. Their primary use is to display information and provide functionality across clusters, including customized data and functionality unique to your extension.
@ -230,7 +238,7 @@ Typically, you would use a [global page menu](#globalpagemenus) located in the l
The following example defines a `LensRendererExtension` subclass with a single global page definition: The following example defines a `LensRendererExtension` subclass with a single global page definition:
``` typescript ```typescript
import { LensRendererExtension } from '@k8slens/extensions'; import { LensRendererExtension } from '@k8slens/extensions';
import { HelpPage } from './page'; import { HelpPage } from './page';
import React from 'react'; import React from 'react';
@ -255,7 +263,7 @@ export default class HelpExtension extends LensRendererExtension {
`HelpPage` in the example above can be defined in `page.tsx`: `HelpPage` in the example above can be defined in `page.tsx`:
``` typescript ```typescript
import { LensRendererExtension } from "@k8slens/extensions"; import { LensRendererExtension } from "@k8slens/extensions";
import React from "react" import React from "react"
@ -284,7 +292,7 @@ This example code shows how to create a global page, but not how to make that pa
By expanding on the above example, you can add a global page menu item to the `HelpExtension` definition: By expanding on the above example, you can add a global page menu item to the `HelpExtension` definition:
``` typescript ```typescript
import { LensRendererExtension } from "@k8slens/extensions"; import { LensRendererExtension } from "@k8slens/extensions";
import { HelpIcon, HelpPage } from "./page" import { HelpIcon, HelpPage } from "./page"
import React from "react" import React from "react"
@ -322,7 +330,7 @@ The above example creates a "Help" icon menu item. When users click the icon, th
This example requires the definition of another React-based component, `HelpIcon`. Update `page.tsx` from the example above with the `HelpIcon` definition, as follows: This example requires the definition of another React-based component, `HelpIcon`. Update `page.tsx` from the example above with the `HelpIcon` definition, as follows:
``` typescript ```typescript
import { LensRendererExtension, Component } from "@k8slens/extensions"; import { LensRendererExtension, Component } from "@k8slens/extensions";
import React from "react" import React from "react"
@ -359,7 +367,7 @@ They can be installed and uninstalled by the Lens user from the cluster **Settin
The following example shows how to add a cluster feature as part of a `LensRendererExtension`: The following example shows how to add a cluster feature as part of a `LensRendererExtension`:
``` typescript ```typescript
import { LensRendererExtension } from "@k8slens/extensions" import { LensRendererExtension } from "@k8slens/extensions"
import { ExampleFeature } from "./src/example-feature" import { ExampleFeature } from "./src/example-feature"
import React from "react" import React from "react"
@ -388,7 +396,7 @@ The properties of the `clusterFeatures` array objects are defined as follows:
* `title` and `components.Description` provide content that appears on the cluster settings page, in the **Features** section. * `title` and `components.Description` provide content that appears on the cluster settings page, in the **Features** section.
* `feature` specifies an instance which extends the abstract class `ClusterFeature.Feature`, and specifically implements the following methods: * `feature` specifies an instance which extends the abstract class `ClusterFeature.Feature`, and specifically implements the following methods:
``` typescript ```typescript
abstract install(cluster: Cluster): Promise<void>; abstract install(cluster: Cluster): Promise<void>;
abstract upgrade(cluster: Cluster): Promise<void>; abstract upgrade(cluster: Cluster): Promise<void>;
abstract uninstall(cluster: Cluster): Promise<void>; abstract uninstall(cluster: Cluster): Promise<void>;
@ -397,13 +405,19 @@ The properties of the `clusterFeatures` array objects are defined as follows:
The four methods listed above are defined as follows: The four methods listed above are defined as follows:
* The `install()` method installs Kubernetes resources using the `applyResources()` method, or by directly accessing the [Kubernetes API](../api/README.md). This method is typically called when a user indicates that they want to install the feature (i.e., by clicking **Install** for the feature in the cluster settings page). * The `install()` method installs Kubernetes resources using the `applyResources()` method, or by directly accessing the [Kubernetes API](../api/README.md).
This method is typically called when a user indicates that they want to install the feature (i.e., by clicking **Install** for the feature in the cluster settings page).
* The `upgrade()` method upgrades the Kubernetes resources already installed, if they are relevant to the feature. This method is typically called when a user indicates that they want to upgrade the feature (i.e., by clicking **Upgrade** for the feature in the cluster settings page). * The `upgrade()` method upgrades the Kubernetes resources already installed, if they are relevant to the feature.
This method is typically called when a user indicates that they want to upgrade the feature (i.e., by clicking **Upgrade** for the feature in the cluster settings page).
* The `uninstall()` method uninstalls Kubernetes resources using the [Kubernetes API](../api/README.md). This method is typically called when a user indicates that they want to uninstall the feature (i.e., by clicking **Uninstall** for the feature in the cluster settings page). * The `uninstall()` method uninstalls Kubernetes resources using the [Kubernetes API](../api/README.md).
This method is typically called when a user indicates that they want to uninstall the feature (i.e., by clicking **Uninstall** for the feature in the cluster settings page).
* The `updateStatus()` method provides the current status information in the `status` field of the `ClusterFeature.Feature` parent class. Lens periodically calls this method to determine details about the feature's current status. Consider using the following properties with `updateStatus()`: * The `updateStatus()` method provides the current status information in the `status` field of the `ClusterFeature.Feature` parent class.
Lens periodically calls this method to determine details about the feature's current status.
The implementation of this method should uninstall Kubernetes resources using the Kubernetes api (`K8sApi`)
Consider using the following properties with `updateStatus()`:
* `status.currentVersion` and `status.latestVersion` may be displayed by Lens in the feature's description. * `status.currentVersion` and `status.latestVersion` may be displayed by Lens in the feature's description.
@ -413,7 +427,7 @@ The four methods listed above are defined as follows:
The following shows a very simple implementation of a `ClusterFeature`: The following shows a very simple implementation of a `ClusterFeature`:
``` typescript ```typescript
import { ClusterFeature, Store, K8sApi } from "@k8slens/extensions"; import { ClusterFeature, Store, K8sApi } from "@k8slens/extensions";
import * as path from "path"; import * as path from "path";
@ -487,7 +501,7 @@ The Lens **Preferences** page is a built-in global page. You can use Lens extens
The following example demonstrates adding a custom preference: The following example demonstrates adding a custom preference:
``` typescript ```typescript
import { LensRendererExtension } from "@k8slens/extensions"; import { LensRendererExtension } from "@k8slens/extensions";
import { ExamplePreferenceHint, ExamplePreferenceInput } from "./src/example-preference"; import { ExamplePreferenceHint, ExamplePreferenceInput } from "./src/example-preference";
import { observable } from "mobx"; import { observable } from "mobx";
@ -524,7 +538,7 @@ export default class ExampleRendererExtension extends LensRendererExtension {
In this example `ExamplePreferenceInput`, `ExamplePreferenceHint`, and `ExamplePreferenceProps` are defined in `./src/example-preference.tsx` as follows: In this example `ExamplePreferenceInput`, `ExamplePreferenceHint`, and `ExamplePreferenceProps` are defined in `./src/example-preference.tsx` as follows:
``` typescript ```typescript
import { Component } from "@k8slens/extensions"; import { Component } from "@k8slens/extensions";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import React from "react"; import React from "react";
@ -580,7 +594,7 @@ The status bar is the blue strip along the bottom of the Lens UI. `statusBarItem
The following example adds a `statusBarItems` definition and a `globalPages` definition to a `LensRendererExtension` subclass. It configures the status bar item to navigate to the global page upon activation (normally a mouse click): The following example adds a `statusBarItems` definition and a `globalPages` definition to a `LensRendererExtension` subclass. It configures the status bar item to navigate to the global page upon activation (normally a mouse click):
``` typescript ```typescript
import { LensRendererExtension } from '@k8slens/extensions'; import { LensRendererExtension } from '@k8slens/extensions';
import { HelpIcon, HelpPage } from "./page" import { HelpIcon, HelpPage } from "./page"
import React from 'react'; import React from 'react';
@ -597,15 +611,17 @@ export default class HelpExtension extends LensRendererExtension {
statusBarItems = [ statusBarItems = [
{ {
item: ( components: {
<div Item: (
className="flex align-center gaps" <div
onClick={() => this.navigate("help")} className="flex align-center gaps"
> onClick={() => this.navigate("help")}
<HelpIcon /> >
My Status Bar Item <HelpIcon />
</div> My Status Bar Item
), </div>
)
},
}, },
]; ];
} }
@ -613,7 +629,7 @@ export default class HelpExtension extends LensRendererExtension {
The properties of the `statusBarItems` array objects are defined as follows: The properties of the `statusBarItems` array objects are defined as follows:
* `item` specifies the `React.Component` that will be shown on the status bar. By default, items are added starting from the right side of the status bar. Due to limited space in the status bar, `item` will typically specify only an icon or a short string of text. The example above reuses the `HelpIcon` from the [`globalPageMenus` guide](#globalpagemenus). * `Item` specifies the `React.Component` that will be shown on the status bar. By default, items are added starting from the right side of the status bar. Due to limited space in the status bar, `Item` will typically specify only an icon or a short string of text. The example above reuses the `HelpIcon` from the [`globalPageMenus` guide](#globalpagemenus).
* `onClick` determines what the `statusBarItem` does when it is clicked. In the example, `onClick` is set to a function that calls the `LensRendererExtension` `navigate()` method. `navigate` takes the `id` of the associated global page as a parameter. Thus, clicking the status bar item activates the associated global pages. * `onClick` determines what the `statusBarItem` does when it is clicked. In the example, `onClick` is set to a function that calls the `LensRendererExtension` `navigate()` method. `navigate` takes the `id` of the associated global page as a parameter. Thus, clicking the status bar item activates the associated global pages.
### `kubeObjectMenuItems` ### `kubeObjectMenuItems`
@ -629,7 +645,7 @@ They also appear on the title bar of the details page for specific resources:
The following example shows how to add a `kubeObjectMenuItems` for namespace resources with an associated action: The following example shows how to add a `kubeObjectMenuItems` for namespace resources with an associated action:
``` typescript ```typescript
import React from "react" import React from "react"
import { LensRendererExtension } from "@k8slens/extensions"; import { LensRendererExtension } from "@k8slens/extensions";
import { NamespaceMenuItem } from "./src/namespace-menu-item" import { NamespaceMenuItem } from "./src/namespace-menu-item"
@ -702,7 +718,7 @@ These custom details appear on the details page for a specific resource, such as
The following example shows how to use `kubeObjectDetailItems` to add a tabulated list of pods to the Namespace resource details page: The following example shows how to use `kubeObjectDetailItems` to add a tabulated list of pods to the Namespace resource details page:
``` typescript ```typescript
import React from "react" import React from "react"
import { LensRendererExtension } from "@k8slens/extensions"; import { LensRendererExtension } from "@k8slens/extensions";
import { NamespaceDetailsItem } from "./src/namespace-details-item" import { NamespaceDetailsItem } from "./src/namespace-details-item"
@ -757,9 +773,20 @@ export class NamespaceDetailsItem extends React.Component<Component.KubeObjectDe
} }
``` ```
Since `NamespaceDetailsItem` extends `React.Component<Component.KubeObjectDetailsProps<K8sApi.Namespace>>`, it can access the current namespace object (type `K8sApi.Namespace`) through `this.props.object`. You can query this object for many details about the current namespace. In the example above, `componentDidMount()` gets the namespace's name using the `K8sApi.Namespace` `getName()` method. Use the namespace's name to limit the list of pods only to those in the relevant namespace. To get this list of pods, this example uses the Kubernetes pods API `K8sApi.podsApi.list()` method. The `K8sApi.podsApi` is automatically configured for the active cluster. Since `NamespaceDetailsItem` extends `React.Component<Component.KubeObjectDetailsProps<K8sApi.Namespace>>`, it can access the current namespace object (type `K8sApi.Namespace`) through `this.props.object`.
You can query this object for many details about the current namespace.
In the example above, `componentDidMount()` gets the namespace's name using the `K8sApi.Namespace` `getName()` method.
Use the namespace's name to limit the list of pods only to those in the relevant namespace.
To get this list of pods, this example uses the Kubernetes pods API `K8sApi.podsApi.list()` method.
The `K8sApi.podsApi` is automatically configured for the active cluster.
Note that `K8sApi.podsApi.list()` is an asynchronous method. Getting the pods list should occur prior to rendering the `NamespaceDetailsItem`. It is a common technique in React development to await async calls in `componentDidMount()`. However, `componentDidMount()` is called right after the first call to `render()`. In order to effect a subsequent `render()` call, React must be made aware of a state change. Like in the [`appPreferences` guide](#apppreferences), [`mobx`](https://mobx.js.org/README.html) and [`mobx-react`](https://github.com/mobxjs/mobx-react#mobx-react) are used to ensure `NamespaceDetailsItem` renders when the pods list updates. This is done simply by marking the `pods` field as an `observable` and the `NamespaceDetailsItem` class itself as an `observer`. Note that `K8sApi.podsApi.list()` is an asynchronous method.
Getting the pods list should occur prior to rendering the `NamespaceDetailsItem`.
It is a common technique in React development to await async calls in `componentDidMount()`.
However, `componentDidMount()` is called right after the first call to `render()`.
In order to effect a subsequent `render()` call, React must be made aware of a state change.
Like in the [`appPreferences` guide](#apppreferences), [`mobx`](https://mobx.js.org/README.html) and [`mobx-react`](https://github.com/mobxjs/mobx-react#mobx-react) are used to ensure `NamespaceDetailsItem` renders when the pods list updates.
This is done simply by marking the `pods` field as an `observable` and the `NamespaceDetailsItem` class itself as an `observer`.
Finally, the `NamespaceDetailsItem` renders using the `render()` method. Finally, the `NamespaceDetailsItem` renders using the `render()` method.
Details are placed in drawers, and using `Component.DrawerTitle` provides a separator from details above this one. Details are placed in drawers, and using `Component.DrawerTitle` provides a separator from details above this one.

View File

@ -10,7 +10,7 @@ For example, I have a component `GlobalPageMenuIcon` and want to test if `props.
My component `GlobalPageMenuIcon` My component `GlobalPageMenuIcon`
```tsx ```typescript
import React from "react" import React from "react"
import { Component: { Icon } } from "@k8slens/extensions"; import { Component: { Icon } } from "@k8slens/extensions";

View File

@ -2796,7 +2796,8 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz",
"integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=",
"dev": true "dev": true,
"optional": true
}, },
"har-schema": { "har-schema": {
"version": "2.0.0", "version": "2.0.0",
@ -3226,6 +3227,7 @@
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"is-docker": "^2.0.0" "is-docker": "^2.0.0"
} }
@ -4367,6 +4369,7 @@
"resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz",
"integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"growly": "^1.3.0", "growly": "^1.3.0",
"is-wsl": "^2.2.0", "is-wsl": "^2.2.0",
@ -4381,6 +4384,7 @@
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"yallist": "^4.0.0" "yallist": "^4.0.0"
} }
@ -4390,6 +4394,7 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz",
"integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"lru-cache": "^6.0.0" "lru-cache": "^6.0.0"
} }
@ -4399,6 +4404,7 @@
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"isexe": "^2.0.0" "isexe": "^2.0.0"
} }
@ -4407,7 +4413,8 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true "dev": true,
"optional": true
} }
} }
}, },
@ -5398,7 +5405,8 @@
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz",
"integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==",
"dev": true "dev": true,
"optional": true
}, },
"signal-exit": { "signal-exit": {
"version": "3.0.3", "version": "3.0.3",
@ -6275,7 +6283,8 @@
"version": "8.3.1", "version": "8.3.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz",
"integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==", "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==",
"dev": true "dev": true,
"optional": true
}, },
"v8-to-istanbul": { "v8-to-istanbul": {
"version": "7.0.0", "version": "7.0.0",

View File

@ -2868,7 +2868,8 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz",
"integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=",
"dev": true "dev": true,
"optional": true
}, },
"har-schema": { "har-schema": {
"version": "2.0.0", "version": "2.0.0",
@ -3298,6 +3299,7 @@
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"is-docker": "^2.0.0" "is-docker": "^2.0.0"
} }
@ -4460,6 +4462,7 @@
"resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz",
"integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"growly": "^1.3.0", "growly": "^1.3.0",
"is-wsl": "^2.2.0", "is-wsl": "^2.2.0",
@ -4474,6 +4477,7 @@
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"yallist": "^4.0.0" "yallist": "^4.0.0"
} }
@ -4483,6 +4487,7 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz",
"integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"lru-cache": "^6.0.0" "lru-cache": "^6.0.0"
} }
@ -4492,6 +4497,7 @@
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"isexe": "^2.0.0" "isexe": "^2.0.0"
} }
@ -4500,7 +4506,8 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true "dev": true,
"optional": true
} }
} }
}, },
@ -5516,7 +5523,8 @@
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz",
"integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==",
"dev": true "dev": true,
"optional": true
}, },
"signal-exit": { "signal-exit": {
"version": "3.0.3", "version": "3.0.3",
@ -6406,7 +6414,8 @@
"version": "8.3.1", "version": "8.3.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz",
"integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==", "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==",
"dev": true "dev": true,
"optional": true
}, },
"v8-to-istanbul": { "v8-to-istanbul": {
"version": "7.0.0", "version": "7.0.0",

View File

@ -2816,7 +2816,8 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz",
"integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=",
"dev": true "dev": true,
"optional": true
}, },
"har-schema": { "har-schema": {
"version": "2.0.0", "version": "2.0.0",
@ -3246,6 +3247,7 @@
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"is-docker": "^2.0.0" "is-docker": "^2.0.0"
} }
@ -4394,6 +4396,7 @@
"resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz",
"integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"growly": "^1.3.0", "growly": "^1.3.0",
"is-wsl": "^2.2.0", "is-wsl": "^2.2.0",
@ -4408,6 +4411,7 @@
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"isexe": "^2.0.0" "isexe": "^2.0.0"
} }
@ -5434,7 +5438,8 @@
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz",
"integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==",
"dev": true "dev": true,
"optional": true
}, },
"signal-exit": { "signal-exit": {
"version": "3.0.3", "version": "3.0.3",
@ -6311,7 +6316,8 @@
"version": "8.3.1", "version": "8.3.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz",
"integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==", "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==",
"dev": true "dev": true,
"optional": true
}, },
"v8-to-istanbul": { "v8-to-istanbul": {
"version": "7.0.0", "version": "7.0.0",

View File

@ -2796,7 +2796,8 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz",
"integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=",
"dev": true "dev": true,
"optional": true
}, },
"har-schema": { "har-schema": {
"version": "2.0.0", "version": "2.0.0",
@ -3226,6 +3227,7 @@
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"is-docker": "^2.0.0" "is-docker": "^2.0.0"
} }
@ -4382,6 +4384,7 @@
"resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz",
"integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"growly": "^1.3.0", "growly": "^1.3.0",
"is-wsl": "^2.2.0", "is-wsl": "^2.2.0",
@ -4396,6 +4399,7 @@
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"yallist": "^4.0.0" "yallist": "^4.0.0"
} }
@ -4405,6 +4409,7 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz",
"integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"lru-cache": "^6.0.0" "lru-cache": "^6.0.0"
} }
@ -4414,6 +4419,7 @@
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"isexe": "^2.0.0" "isexe": "^2.0.0"
} }
@ -4422,7 +4428,8 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true "dev": true,
"optional": true
} }
} }
}, },
@ -5438,7 +5445,8 @@
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz",
"integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==",
"dev": true "dev": true,
"optional": true
}, },
"signal-exit": { "signal-exit": {
"version": "3.0.3", "version": "3.0.3",
@ -6315,7 +6323,8 @@
"version": "8.3.1", "version": "8.3.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz",
"integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==", "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==",
"dev": true "dev": true,
"optional": true
}, },
"v8-to-istanbul": { "v8-to-istanbul": {
"version": "7.0.0", "version": "7.0.0",

View File

@ -626,7 +626,644 @@
}, },
"@k8slens/extensions": { "@k8slens/extensions": {
"version": "file:../../src/extensions/npm/extensions", "version": "file:../../src/extensions/npm/extensions",
"dev": true "dev": true,
"requires": {
"@material-ui/core": "*",
"@types/node": "*",
"@types/react-select": "*",
"conf": "^7.0.1"
},
"dependencies": {
"@babel/runtime": {
"version": "7.12.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz",
"integrity": "sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==",
"dev": true,
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"@emotion/hash": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
"integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
"dev": true
},
"@material-ui/core": {
"version": "4.11.2",
"resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.11.2.tgz",
"integrity": "sha512-/D1+AQQeYX/WhT/FUk78UCRj8ch/RCglsQLYujYTIqPSJlwZHKcvHidNeVhODXeApojeXjkl0tWdk5C9ofwOkQ==",
"dev": true,
"requires": {
"@babel/runtime": "^7.4.4",
"@material-ui/styles": "^4.11.2",
"@material-ui/system": "^4.11.2",
"@material-ui/types": "^5.1.0",
"@material-ui/utils": "^4.11.2",
"@types/react-transition-group": "^4.2.0",
"clsx": "^1.0.4",
"hoist-non-react-statics": "^3.3.2",
"popper.js": "1.16.1-lts",
"prop-types": "^15.7.2",
"react-is": "^16.8.0 || ^17.0.0",
"react-transition-group": "^4.4.0"
}
},
"@material-ui/styles": {
"version": "4.11.2",
"resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.2.tgz",
"integrity": "sha512-xbItf8zkfD3FuGoD9f2vlcyPf9jTEtj9YTJoNNV+NMWaSAHXgrW6geqRoo/IwBuMjqpwqsZhct13e2nUyU9Ljw==",
"dev": true,
"requires": {
"@babel/runtime": "^7.4.4",
"@emotion/hash": "^0.8.0",
"@material-ui/types": "^5.1.0",
"@material-ui/utils": "^4.11.2",
"clsx": "^1.0.4",
"csstype": "^2.5.2",
"hoist-non-react-statics": "^3.3.2",
"jss": "^10.0.3",
"jss-plugin-camel-case": "^10.0.3",
"jss-plugin-default-unit": "^10.0.3",
"jss-plugin-global": "^10.0.3",
"jss-plugin-nested": "^10.0.3",
"jss-plugin-props-sort": "^10.0.3",
"jss-plugin-rule-value-function": "^10.0.3",
"jss-plugin-vendor-prefixer": "^10.0.3",
"prop-types": "^15.7.2"
}
},
"@material-ui/system": {
"version": "4.11.2",
"resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.11.2.tgz",
"integrity": "sha512-BELFJEel5E+5DMiZb6XXT3peWRn6UixRvBtKwSxqntmD0+zwbbfCij6jtGwwdJhN1qX/aXrKu10zX31GBaeR7A==",
"dev": true,
"requires": {
"@babel/runtime": "^7.4.4",
"@material-ui/utils": "^4.11.2",
"csstype": "^2.5.2",
"prop-types": "^15.7.2"
}
},
"@material-ui/types": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz",
"integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==",
"dev": true
},
"@material-ui/utils": {
"version": "4.11.2",
"resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.2.tgz",
"integrity": "sha512-Uul8w38u+PICe2Fg2pDKCaIG7kOyhowZ9vjiC1FsVwPABTW8vPPKfF6OvxRq3IiBaI1faOJmgdvMG7rMJARBhA==",
"dev": true,
"requires": {
"@babel/runtime": "^7.4.4",
"prop-types": "^15.7.2",
"react-is": "^16.8.0 || ^17.0.0"
}
},
"@types/node": {
"version": "14.14.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.21.tgz",
"integrity": "sha512-cHYfKsnwllYhjOzuC5q1VpguABBeecUp24yFluHpn/BQaVxB1CuQ1FSRZCzrPxrkIfWISXV2LbeoBthLWg0+0A==",
"dev": true
},
"@types/prop-types": {
"version": "15.7.3",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz",
"integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==",
"dev": true
},
"@types/react": {
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.0.tgz",
"integrity": "sha512-aj/L7RIMsRlWML3YB6KZiXB3fV2t41+5RBGYF8z+tAKU43Px8C3cYUZsDvf1/+Bm4FK21QWBrDutu8ZJ/70qOw==",
"dev": true,
"requires": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
},
"dependencies": {
"csstype": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.6.tgz",
"integrity": "sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw==",
"dev": true
}
}
},
"@types/react-dom": {
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.0.tgz",
"integrity": "sha512-lUqY7OlkF/RbNtD5nIq7ot8NquXrdFrjSOR6+w9a9RFQevGi1oZO1dcJbXMeONAPKtZ2UrZOEJ5UOCVsxbLk/g==",
"dev": true,
"requires": {
"@types/react": "*"
}
},
"@types/react-select": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@types/react-select/-/react-select-3.1.2.tgz",
"integrity": "sha512-ygvR/2FL87R2OLObEWFootYzkvm67LRA+URYEAcBuvKk7IXmdsnIwSGm60cVXGaqkJQHozb2Cy1t94tCYb6rJA==",
"dev": true,
"requires": {
"@types/react": "*",
"@types/react-dom": "*",
"@types/react-transition-group": "*"
}
},
"@types/react-transition-group": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.0.tgz",
"integrity": "sha512-/QfLHGpu+2fQOqQaXh8MG9q03bFENooTb/it4jr5kKaZlDQfWvjqWZg48AwzPVMBHlRuTRAY7hRHCEOXz5kV6w==",
"dev": true,
"requires": {
"@types/react": "*"
}
},
"ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
}
},
"atomically": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz",
"integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==",
"dev": true
},
"clsx": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz",
"integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==",
"dev": true
},
"conf": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/conf/-/conf-7.1.2.tgz",
"integrity": "sha512-r8/HEoWPFn4CztjhMJaWNAe5n+gPUCSaJ0oufbqDLFKsA1V8JjAG7G+p0pgoDFAws9Bpk2VtVLLXqOBA7WxLeg==",
"dev": true,
"requires": {
"ajv": "^6.12.2",
"atomically": "^1.3.1",
"debounce-fn": "^4.0.0",
"dot-prop": "^5.2.0",
"env-paths": "^2.2.0",
"json-schema-typed": "^7.0.3",
"make-dir": "^3.1.0",
"onetime": "^5.1.0",
"pkg-up": "^3.1.0",
"semver": "^7.3.2"
}
},
"css-vendor": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz",
"integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==",
"dev": true,
"requires": {
"@babel/runtime": "^7.8.3",
"is-in-browser": "^1.0.2"
}
},
"csstype": {
"version": "2.6.14",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.14.tgz",
"integrity": "sha512-2mSc+VEpGPblzAxyeR+vZhJKgYg0Og0nnRi7pmRXFYYxSfnOnW8A5wwQb4n4cE2nIOzqKOAzLCaEX6aBmNEv8A==",
"dev": true
},
"debounce-fn": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz",
"integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==",
"dev": true,
"requires": {
"mimic-fn": "^3.0.0"
}
},
"dom-helpers": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.0.tgz",
"integrity": "sha512-Ru5o9+V8CpunKnz5LGgWXkmrH/20cGKwcHwS4m73zIvs54CN9epEmT/HLqFJW3kXpakAFkEdzgy1hzlJe3E4OQ==",
"dev": true,
"requires": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
},
"dependencies": {
"csstype": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.6.tgz",
"integrity": "sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw==",
"dev": true
}
}
},
"dot-prop": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",
"integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==",
"dev": true,
"requires": {
"is-obj": "^2.0.0"
}
},
"env-paths": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.0.tgz",
"integrity": "sha512-6u0VYSCo/OW6IoD5WCLLy9JUGARbamfSavcNXry/eu8aHVFei6CD3Sw+VGX5alea1i9pgPHW0mbu6Xj0uBh7gA==",
"dev": true
},
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
"fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true
},
"find-up": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
"integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
"dev": true,
"requires": {
"locate-path": "^3.0.0"
}
},
"hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"dev": true,
"requires": {
"react-is": "^16.7.0"
},
"dependencies": {
"react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
}
}
},
"hyphenate-style-name": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz",
"integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==",
"dev": true
},
"indefinite-observable": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/indefinite-observable/-/indefinite-observable-2.0.1.tgz",
"integrity": "sha512-G8vgmork+6H9S8lUAg1gtXEj2JxIQTo0g2PbFiYOdjkziSI0F7UYBiVwhZRuixhBCNGczAls34+5HJPyZysvxQ==",
"dev": true,
"requires": {
"symbol-observable": "1.2.0"
}
},
"is-in-browser": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz",
"integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU=",
"dev": true
},
"is-obj": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz",
"integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==",
"dev": true
},
"js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true
},
"json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true
},
"json-schema-typed": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz",
"integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==",
"dev": true
},
"jss": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/jss/-/jss-10.5.0.tgz",
"integrity": "sha512-B6151NvG+thUg3murLNHRPLxTLwQ13ep4SH5brj4d8qKtogOx/jupnpfkPGSHPqvcwKJaCLctpj2lEk+5yGwMw==",
"dev": true,
"requires": {
"@babel/runtime": "^7.3.1",
"csstype": "^3.0.2",
"indefinite-observable": "^2.0.1",
"is-in-browser": "^1.1.3",
"tiny-warning": "^1.0.2"
},
"dependencies": {
"csstype": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.6.tgz",
"integrity": "sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw==",
"dev": true
}
}
},
"jss-plugin-camel-case": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.5.0.tgz",
"integrity": "sha512-GSjPL0adGAkuoqeYiXTgO7PlIrmjv5v8lA6TTBdfxbNYpxADOdGKJgIEkffhlyuIZHlPuuiFYTwUreLUmSn7rg==",
"dev": true,
"requires": {
"@babel/runtime": "^7.3.1",
"hyphenate-style-name": "^1.0.3",
"jss": "10.5.0"
}
},
"jss-plugin-default-unit": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.5.0.tgz",
"integrity": "sha512-rsbTtZGCMrbcb9beiDd+TwL991NGmsAgVYH0hATrYJtue9e+LH/Gn4yFD1ENwE+3JzF3A+rPnM2JuD9L/SIIWw==",
"dev": true,
"requires": {
"@babel/runtime": "^7.3.1",
"jss": "10.5.0"
}
},
"jss-plugin-global": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.5.0.tgz",
"integrity": "sha512-FZd9+JE/3D7HMefEG54fEC0XiQ9rhGtDHAT/ols24y8sKQ1D5KIw6OyXEmIdKFmACgxZV2ARQ5pAUypxkk2IFQ==",
"dev": true,
"requires": {
"@babel/runtime": "^7.3.1",
"jss": "10.5.0"
}
},
"jss-plugin-nested": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.5.0.tgz",
"integrity": "sha512-ejPlCLNlEGgx8jmMiDk/zarsCZk+DV0YqXfddpgzbO9Toamo0HweCFuwJ3ZO40UFOfqKwfpKMVH/3HUXgxkTMg==",
"dev": true,
"requires": {
"@babel/runtime": "^7.3.1",
"jss": "10.5.0",
"tiny-warning": "^1.0.2"
}
},
"jss-plugin-props-sort": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.5.0.tgz",
"integrity": "sha512-kTLRvrOetFKz5vM88FAhLNeJIxfjhCepnvq65G7xsAQ/Wgy7HwO1BS/2wE5mx8iLaAWC6Rj5h16mhMk9sKdZxg==",
"dev": true,
"requires": {
"@babel/runtime": "^7.3.1",
"jss": "10.5.0"
}
},
"jss-plugin-rule-value-function": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.5.0.tgz",
"integrity": "sha512-jXINGr8BSsB13JVuK274oEtk0LoooYSJqTBCGeBu2cG/VJ3+4FPs1gwLgsq24xTgKshtZ+WEQMVL34OprLidRA==",
"dev": true,
"requires": {
"@babel/runtime": "^7.3.1",
"jss": "10.5.0",
"tiny-warning": "^1.0.2"
}
},
"jss-plugin-vendor-prefixer": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.5.0.tgz",
"integrity": "sha512-rux3gmfwDdOKCLDx0IQjTwTm03IfBa+Rm/hs747cOw5Q7O3RaTUIMPKjtVfc31Xr/XI9Abz2XEupk1/oMQ7zRA==",
"dev": true,
"requires": {
"@babel/runtime": "^7.3.1",
"css-vendor": "^2.0.8",
"jss": "10.5.0"
}
},
"locate-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
"integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
"dev": true,
"requires": {
"p-locate": "^3.0.0",
"path-exists": "^3.0.0"
}
},
"loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"requires": {
"js-tokens": "^3.0.0 || ^4.0.0"
}
},
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"requires": {
"yallist": "^4.0.0"
}
},
"make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
"dev": true,
"requires": {
"semver": "^6.0.0"
},
"dependencies": {
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"dev": true
}
}
},
"mimic-fn": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz",
"integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==",
"dev": true
},
"object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
"dev": true
},
"onetime": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
"dev": true,
"requires": {
"mimic-fn": "^2.1.0"
},
"dependencies": {
"mimic-fn": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
"dev": true
}
}
},
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"requires": {
"p-try": "^2.0.0"
}
},
"p-locate": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
"integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
"dev": true,
"requires": {
"p-limit": "^2.0.0"
}
},
"p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true
},
"path-exists": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
"integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=",
"dev": true
},
"pkg-up": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz",
"integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==",
"dev": true,
"requires": {
"find-up": "^3.0.0"
}
},
"popper.js": {
"version": "1.16.1-lts",
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1-lts.tgz",
"integrity": "sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA==",
"dev": true
},
"prop-types": {
"version": "15.7.2",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz",
"integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==",
"dev": true,
"requires": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.8.1"
},
"dependencies": {
"react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
}
}
},
"punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
"dev": true
},
"react-is": {
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz",
"integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==",
"dev": true
},
"react-transition-group": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz",
"integrity": "sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==",
"dev": true,
"requires": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
}
},
"regenerator-runtime": {
"version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==",
"dev": true
},
"semver": {
"version": "7.3.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz",
"integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==",
"dev": true,
"requires": {
"lru-cache": "^6.0.0"
}
},
"symbol-observable": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz",
"integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==",
"dev": true
},
"tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
"dev": true
},
"uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"requires": {
"punycode": "^2.1.0"
}
},
"yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
}
}
}, },
"@sinonjs/commons": { "@sinonjs/commons": {
"version": "1.8.1", "version": "1.8.1",
@ -2796,7 +3433,8 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz",
"integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=",
"dev": true "dev": true,
"optional": true
}, },
"har-schema": { "har-schema": {
"version": "2.0.0", "version": "2.0.0",
@ -3226,6 +3864,7 @@
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"is-docker": "^2.0.0" "is-docker": "^2.0.0"
} }
@ -4382,6 +5021,7 @@
"resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz",
"integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"growly": "^1.3.0", "growly": "^1.3.0",
"is-wsl": "^2.2.0", "is-wsl": "^2.2.0",
@ -4396,6 +5036,7 @@
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"yallist": "^4.0.0" "yallist": "^4.0.0"
} }
@ -4405,6 +5046,7 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz",
"integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"lru-cache": "^6.0.0" "lru-cache": "^6.0.0"
} }
@ -4414,6 +5056,7 @@
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"isexe": "^2.0.0" "isexe": "^2.0.0"
} }
@ -4422,7 +5065,8 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true "dev": true,
"optional": true
} }
} }
}, },
@ -5438,7 +6082,8 @@
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz",
"integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==",
"dev": true "dev": true,
"optional": true
}, },
"signal-exit": { "signal-exit": {
"version": "3.0.3", "version": "3.0.3",
@ -6315,7 +6960,8 @@
"version": "8.3.1", "version": "8.3.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz",
"integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==", "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==",
"dev": true "dev": true,
"optional": true
}, },
"v8-to-istanbul": { "v8-to-istanbul": {
"version": "7.0.0", "version": "7.0.0",

View File

@ -9,13 +9,9 @@ export class PodLogsMenu extends React.Component<PodLogsMenuProps> {
Navigation.hideDetails(); Navigation.hideDetails();
const pod = this.props.object; const pod = this.props.object;
Component.createPodLogsTab({ Component.logTabStore.createPodTab({
pod, selectedPod: pod,
containers: pod.getContainers(),
initContainers: pod.getInitContainers(),
selectedContainer: container, selectedContainer: container,
showTimestamps: false,
previous: false,
}); });
} }

View File

@ -0,0 +1,9 @@
import { LensMainExtension } from "@k8slens/extensions";
import { surveyPreferencesStore } from "./src/survey-preferences-store";
export default class SurveyMainExtension extends LensMainExtension {
async onActivate() {
await surveyPreferencesStore.loadExtension(this);
}
}

7928
extensions/survey/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
{
"name": "lens-survey",
"version": "0.1.0",
"description": "Lens survey",
"main": "dist/main.js",
"renderer": "dist/renderer.js",
"lens": {
"metadata": {},
"styles": []
},
"scripts": {
"build": "webpack -p",
"dev": "webpack --watch",
"test": "jest --passWithNoTests --env=jsdom src $@"
},
"dependencies": {},
"devDependencies": {
"@k8slens/extensions": "file:../../src/extensions/npm/extensions",
"got": "^11.8.1",
"jest": "^26.6.3",
"node-machine-id": "^1.1.12",
"react": "^16.13.1",
"refiner-js": "^1.0.1",
"ts-loader": "^8.0.4",
"typescript": "^4.0.3",
"webpack": "^4.44.2"
}
}

View File

@ -0,0 +1,21 @@
import { LensRendererExtension } from "@k8slens/extensions";
import { survey } from "./src/survey";
import { SurveyPreferenceHint, SurveyPreferenceInput } from "./src/survey-preference";
import { surveyPreferencesStore } from "./src/survey-preferences-store";
import React from "react";
export default class SurveyRendererExtension extends LensRendererExtension {
appPreferences = [
{
title: "In-App Surveys",
components: {
Hint: () => <SurveyPreferenceHint/>,
Input: () => <SurveyPreferenceInput survey={surveyPreferencesStore}/>
}
}
];
async onActivate() {
await surveyPreferencesStore.loadExtension(this);
survey.start();
}
}

3
extensions/survey/src/refiner-js.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
declare module "refiner-js" {
export default function Refiner(key: string, value: string|object|number|Boolean|Array): void;
}

View File

@ -0,0 +1,27 @@
import { Component } from "@k8slens/extensions";
import React from "react";
import { observer } from "mobx-react";
import { SurveyPreferencesStore } from "./survey-preferences-store";
@observer
export class SurveyPreferenceInput extends React.Component<{survey: SurveyPreferencesStore}, {}> {
render() {
const { survey } = this.props;
return (
<Component.Checkbox
label="Allow in-app surveys"
value={survey.enabled}
onChange={v => survey.enabled = v }
/>
);
}
}
export class SurveyPreferenceHint extends React.Component {
render() {
return (
<span>This will allow you to participate in surveys to improve the Lens experience.</span>
);
}
}

View File

@ -0,0 +1,36 @@
import { Store } from "@k8slens/extensions";
import { observable, toJS, when } from "mobx";
export type SurveyPreferencesModel = {
enabled: boolean;
};
export class SurveyPreferencesStore extends Store.ExtensionStore<SurveyPreferencesModel> {
@observable enabled = true;
whenEnabled = when(() => this.enabled);
private constructor() {
super({
configName: "preferences-store",
defaults: {
enabled: true
}
});
}
protected fromStore({ enabled }: SurveyPreferencesModel): void {
this.enabled = enabled;
}
toJSON(): SurveyPreferencesModel {
return toJS({
enabled: this.enabled
}, {
recurseEverything: true
});
}
}
export const surveyPreferencesStore = SurveyPreferencesStore.getInstance<SurveyPreferencesStore>();

View File

@ -0,0 +1,46 @@
import { Util } from "@k8slens/extensions";
import { machineId } from "node-machine-id";
import Refiner from "refiner-js";
import got from "got";
import { surveyPreferencesStore } from "./survey-preferences-store";
type SurveyIdResponse = {
surveyId: string;
};
export class Survey extends Util.Singleton {
static readonly PROJECT_ID = "af468d00-4f8f-11eb-b01d-23b6562fef43";
protected anonymousId: string;
private constructor() {
super();
}
async start() {
await surveyPreferencesStore.whenEnabled;
const surveyId = await this.fetchSurveyId();
if (surveyId) {
Refiner("setProject", Survey.PROJECT_ID);
Refiner("identifyUser", {
id: surveyId,
});
}
}
async fetchSurveyId() {
try {
const surveyApi = process.env.SURVEY_API_URL || "https://survey.k8slens.dev";
const anonymousId = await machineId();
const { body } = await got(`${surveyApi}/api/survey-id?anonymousId=${anonymousId}`, { responseType: "json"});
return (body as SurveyIdResponse).surveyId;
} catch(error) {
return null;
}
}
}
export const survey = Survey.getInstance<Survey>();

View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"outDir": "dist",
"baseUrl": ".",
"module": "CommonJS",
"target": "ES2017",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"moduleResolution": "Node",
"sourceMap": false,
"declaration": false,
"strict": false,
"noImplicitAny": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"jsx": "react",
"paths": {
"*": [
"node_modules/*",
"../../types/*"
]
}
},
"include": [
"renderer.ts",
"src/**/*"
]
}

View File

@ -0,0 +1,67 @@
const path = require("path");
module.exports = [
{
entry: "./main.ts",
context: __dirname,
target: "electron-main",
mode: "production",
module: {
rules: [
{
test: /\.tsx?$/,
use: "ts-loader",
exclude: /node_modules/,
},
],
},
externals: [
{
"@k8slens/extensions": "var global.LensExtensions",
"react": "var global.React",
"mobx": "var global.Mobx"
}
],
resolve: {
extensions: [ ".tsx", ".ts", ".js" ],
},
output: {
libraryTarget: "commonjs2",
globalObject: "this",
filename: "main.js",
path: path.resolve(__dirname, "dist"),
},
},
{
entry: "./renderer.tsx",
context: __dirname,
target: "electron-renderer",
mode: "production",
module: {
rules: [
{
test: /\.tsx?$/,
use: "ts-loader",
exclude: /node_modules/,
},
],
},
externals: [
{
"@k8slens/extensions": "var global.LensExtensions",
"react": "var global.React",
"mobx": "var global.Mobx",
"mobx-react": "var global.MobxReact"
}
],
resolve: {
extensions: [ ".tsx", ".ts", ".js" ],
},
output: {
libraryTarget: "commonjs2",
globalObject: "this",
filename: "renderer.js",
path: path.resolve(__dirname, "dist"),
},
},
];

View File

@ -2901,7 +2901,8 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz",
"integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=",
"dev": true "dev": true,
"optional": true
}, },
"har-schema": { "har-schema": {
"version": "2.0.0", "version": "2.0.0",
@ -3337,6 +3338,7 @@
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"is-docker": "^2.0.0" "is-docker": "^2.0.0"
} }
@ -4533,6 +4535,7 @@
"resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz",
"integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"growly": "^1.3.0", "growly": "^1.3.0",
"is-wsl": "^2.2.0", "is-wsl": "^2.2.0",
@ -4547,6 +4550,7 @@
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"yallist": "^4.0.0" "yallist": "^4.0.0"
} }
@ -4556,6 +4560,7 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz",
"integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"lru-cache": "^6.0.0" "lru-cache": "^6.0.0"
} }
@ -4564,13 +4569,15 @@
"version": "8.3.2", "version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true "dev": true,
"optional": true
}, },
"which": { "which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"isexe": "^2.0.0" "isexe": "^2.0.0"
} }
@ -4579,7 +4586,8 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true "dev": true,
"optional": true
} }
} }
}, },
@ -5595,7 +5603,8 @@
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz",
"integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==",
"dev": true "dev": true,
"optional": true
}, },
"signal-exit": { "signal-exit": {
"version": "3.0.3", "version": "3.0.3",

View File

@ -1,76 +1,17 @@
/*
Cluster tests are run if there is a pre-existing minikube cluster. Before running cluster tests the TEST_NAMESPACE
namespace is removed, if it exists, from the minikube cluster. Resources are created as part of the cluster tests in the
TEST_NAMESPACE namespace. This is done to minimize destructive impact of the cluster tests on an existing minikube
cluster and vice versa.
*/
import { Application } from "spectron"; import { Application } from "spectron";
import * as utils from "../helpers/utils"; import * as utils from "../helpers/utils";
import { spawnSync, exec } from "child_process"; import { listHelmRepositories } from "../helpers/utils";
import * as util from "util"; import { fail } from "assert";
export const promiseExec = util.promisify(exec);
jest.setTimeout(60000); jest.setTimeout(60000);
// FIXME (!): improve / simplify all css-selectors + use [data-test-id="some-id"] (already used in some tests below) // FIXME (!): improve / simplify all css-selectors + use [data-test-id="some-id"] (already used in some tests below)
describe("Lens integration tests", () => { describe("Lens integration tests", () => {
const TEST_NAMESPACE = "integration-tests";
const BACKSPACE = "\uE003";
let app: Application; let app: Application;
const appStart = async () => {
app = utils.setup();
await app.start();
// Wait for splash screen to be closed
while (await app.client.getWindowCount() > 1);
await app.client.windowByIndex(0);
await app.client.waitUntilWindowLoaded();
};
const clickWhatsNew = async (app: Application) => {
await app.client.waitUntilTextExists("h1", "What's new?");
await app.client.click("button.primary");
await app.client.waitUntilTextExists("h1", "Welcome");
};
const minikubeReady = (): boolean => {
// determine if minikube is running
{
const { status } = spawnSync("minikube status", { shell: true });
if (status !== 0) {
console.warn("minikube not running");
return false;
}
}
// Remove TEST_NAMESPACE if it already exists
{
const { status } = spawnSync(`minikube kubectl -- get namespace ${TEST_NAMESPACE}`, { shell: true });
if (status === 0) {
console.warn(`Removing existing ${TEST_NAMESPACE} namespace`);
const { status, stdout, stderr } = spawnSync(
`minikube kubectl -- delete namespace ${TEST_NAMESPACE}`,
{ shell: true },
);
if (status !== 0) {
console.warn(`Error removing ${TEST_NAMESPACE} namespace: ${stderr.toString()}`);
return false;
}
console.log(stdout.toString());
}
}
return true;
};
const ready = minikubeReady();
describe("app start", () => { describe("app start", () => {
beforeAll(appStart, 20000); beforeAll(async () => app = await utils.appStart(), 20000);
afterAll(async () => { afterAll(async () => {
if (app?.isRunning()) { if (app?.isRunning()) {
@ -79,7 +20,7 @@ describe("Lens integration tests", () => {
}); });
it('shows "whats new"', async () => { it('shows "whats new"', async () => {
await clickWhatsNew(app); await utils.clickWhatsNew(app);
}); });
it('shows "add cluster"', async () => { it('shows "add cluster"', async () => {
@ -96,8 +37,11 @@ describe("Lens integration tests", () => {
}); });
it("ensures helm repos", async () => { it("ensures helm repos", async () => {
const { stdout: reposJson } = await promiseExec("helm repo list -o json"); const repos = await listHelmRepositories();
const repos = JSON.parse(reposJson);
if (!repos[0]) {
fail("Lens failed to add Bitnami repository");
}
await app.client.waitUntilTextExists("div.repos #message-bitnami", repos[0].name); // wait for the helm-cli to fetch the repo(s) await app.client.waitUntilTextExists("div.repos #message-bitnami", repos[0].name); // wait for the helm-cli to fetch the repo(s)
await app.client.click("#HelmRepoSelect"); // click the repo select to activate the drop-down await app.client.click("#HelmRepoSelect"); // click the repo select to activate the drop-down
@ -110,476 +54,4 @@ describe("Lens integration tests", () => {
await app.client.keys("Meta"); await app.client.keys("Meta");
}); });
}); });
utils.describeIf(ready)("workspaces", () => {
beforeAll(appStart, 20000);
afterAll(async () => {
if (app && app.isRunning()) {
return utils.tearDown(app);
}
});
it("creates new workspace", async () => {
await clickWhatsNew(app);
await app.client.click("#current-workspace .Icon");
await app.client.click('a[href="/workspaces"]');
await app.client.click(".Workspaces button.Button");
await app.client.keys("test-workspace");
await app.client.click(".Workspaces .Input.description input");
await app.client.keys("test description");
await app.client.click(".Workspaces .workspace.editing .Icon");
await app.client.waitUntilTextExists(".workspace .name a", "test-workspace");
});
it("adds cluster in default workspace", async () => {
await addMinikubeCluster(app);
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
await app.client.waitForExist(`iframe[name="minikube"]`);
await app.client.waitForVisible(".ClustersMenu .ClusterIcon.active");
});
it("adds cluster in test-workspace", async () => {
await app.client.click("#current-workspace .Icon");
await app.client.waitForVisible('.WorkspaceMenu li[title="test description"]');
await app.client.click('.WorkspaceMenu li[title="test description"]');
await addMinikubeCluster(app);
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
await app.client.waitForExist(`iframe[name="minikube"]`);
});
it("checks if default workspace has active cluster", async () => {
await app.client.click("#current-workspace .Icon");
await app.client.waitForVisible(".WorkspaceMenu > li:first-of-type");
await app.client.click(".WorkspaceMenu > li:first-of-type");
await app.client.waitForVisible(".ClustersMenu .ClusterIcon.active");
});
});
const addMinikubeCluster = async (app: Application) => {
await app.client.click("div.add-cluster");
await app.client.waitUntilTextExists("div", "Select kubeconfig file");
await app.client.click("div.Select__control"); // show the context drop-down list
await app.client.waitUntilTextExists("div", "minikube");
if (!await app.client.$("button.primary").isEnabled()) {
await app.client.click("div.minikube"); // select minikube context
} // else the only context, which must be 'minikube', is automatically selected
await app.client.click("div.Select__control"); // hide the context drop-down list (it might be obscuring the Add cluster(s) button)
await app.client.click("button.primary"); // add minikube cluster
};
const waitForMinikubeDashboard = async (app: Application) => {
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
await app.client.waitForExist(`iframe[name="minikube"]`);
await app.client.frame("minikube");
await app.client.waitUntilTextExists("span.link-text", "Cluster");
};
utils.describeIf(ready)("cluster tests", () => {
let clusterAdded = false;
const addCluster = async () => {
await clickWhatsNew(app);
await addMinikubeCluster(app);
await waitForMinikubeDashboard(app);
await app.client.click('a[href="/nodes"]');
await app.client.waitUntilTextExists("div.TableCell", "Ready");
};
describe("cluster add", () => {
beforeAll(appStart, 20000);
afterAll(async () => {
if (app && app.isRunning()) {
return utils.tearDown(app);
}
});
it("allows to add a cluster", async () => {
await addCluster();
clusterAdded = true;
});
});
const appStartAddCluster = async () => {
if (clusterAdded) {
await appStart();
await addCluster();
}
};
describe("cluster pages", () => {
beforeAll(appStartAddCluster, 40000);
afterAll(async () => {
if (app && app.isRunning()) {
return utils.tearDown(app);
}
});
const tests: {
drawer?: string
drawerId?: string
pages: {
name: string,
href: string,
expectedSelector: string,
expectedText: string
}[]
}[] = [{
drawer: "",
drawerId: "",
pages: [{
name: "Cluster",
href: "cluster",
expectedSelector: "div.ClusterOverview div.label",
expectedText: "Master"
}]
},
{
drawer: "",
drawerId: "",
pages: [{
name: "Nodes",
href: "nodes",
expectedSelector: "h5.title",
expectedText: "Nodes"
}]
},
{
drawer: "Workloads",
drawerId: "workloads",
pages: [{
name: "Overview",
href: "workloads",
expectedSelector: "h5.box",
expectedText: "Overview"
},
{
name: "Pods",
href: "pods",
expectedSelector: "h5.title",
expectedText: "Pods"
},
{
name: "Deployments",
href: "deployments",
expectedSelector: "h5.title",
expectedText: "Deployments"
},
{
name: "DaemonSets",
href: "daemonsets",
expectedSelector: "h5.title",
expectedText: "Daemon Sets"
},
{
name: "StatefulSets",
href: "statefulsets",
expectedSelector: "h5.title",
expectedText: "Stateful Sets"
},
{
name: "ReplicaSets",
href: "replicasets",
expectedSelector: "h5.title",
expectedText: "Replica Sets"
},
{
name: "Jobs",
href: "jobs",
expectedSelector: "h5.title",
expectedText: "Jobs"
},
{
name: "CronJobs",
href: "cronjobs",
expectedSelector: "h5.title",
expectedText: "Cron Jobs"
}]
},
{
drawer: "Configuration",
drawerId: "config",
pages: [{
name: "ConfigMaps",
href: "configmaps",
expectedSelector: "h5.title",
expectedText: "Config Maps"
},
{
name: "Secrets",
href: "secrets",
expectedSelector: "h5.title",
expectedText: "Secrets"
},
{
name: "Resource Quotas",
href: "resourcequotas",
expectedSelector: "h5.title",
expectedText: "Resource Quotas"
},
{
name: "Limit Ranges",
href: "limitranges",
expectedSelector: "h5.title",
expectedText: "Limit Ranges"
},
{
name: "HPA",
href: "hpa",
expectedSelector: "h5.title",
expectedText: "Horizontal Pod Autoscalers"
},
{
name: "Pod Disruption Budgets",
href: "poddisruptionbudgets",
expectedSelector: "h5.title",
expectedText: "Pod Disruption Budgets"
}]
},
{
drawer: "Network",
drawerId: "networks",
pages: [{
name: "Services",
href: "services",
expectedSelector: "h5.title",
expectedText: "Services"
},
{
name: "Endpoints",
href: "endpoints",
expectedSelector: "h5.title",
expectedText: "Endpoints"
},
{
name: "Ingresses",
href: "ingresses",
expectedSelector: "h5.title",
expectedText: "Ingresses"
},
{
name: "Network Policies",
href: "network-policies",
expectedSelector: "h5.title",
expectedText: "Network Policies"
}]
},
{
drawer: "Storage",
drawerId: "storage",
pages: [{
name: "Persistent Volume Claims",
href: "persistent-volume-claims",
expectedSelector: "h5.title",
expectedText: "Persistent Volume Claims"
},
{
name: "Persistent Volumes",
href: "persistent-volumes",
expectedSelector: "h5.title",
expectedText: "Persistent Volumes"
},
{
name: "Storage Classes",
href: "storage-classes",
expectedSelector: "h5.title",
expectedText: "Storage Classes"
}]
},
{
drawer: "",
drawerId: "",
pages: [{
name: "Namespaces",
href: "namespaces",
expectedSelector: "h5.title",
expectedText: "Namespaces"
}]
},
{
drawer: "",
drawerId: "",
pages: [{
name: "Events",
href: "events",
expectedSelector: "h5.title",
expectedText: "Events"
}]
},
{
drawer: "Apps",
drawerId: "apps",
pages: [{
name: "Charts",
href: "apps/charts",
expectedSelector: "div.HelmCharts input",
expectedText: ""
},
{
name: "Releases",
href: "apps/releases",
expectedSelector: "h5.title",
expectedText: "Releases"
}]
},
{
drawer: "Access Control",
drawerId: "users",
pages: [{
name: "Service Accounts",
href: "service-accounts",
expectedSelector: "h5.title",
expectedText: "Service Accounts"
},
{
name: "Role Bindings",
href: "role-bindings",
expectedSelector: "h5.title",
expectedText: "Role Bindings"
},
{
name: "Roles",
href: "roles",
expectedSelector: "h5.title",
expectedText: "Roles"
},
{
name: "Pod Security Policies",
href: "pod-security-policies",
expectedSelector: "h5.title",
expectedText: "Pod Security Policies"
}]
},
{
drawer: "Custom Resources",
drawerId: "custom-resources",
pages: [{
name: "Definitions",
href: "crd/definitions",
expectedSelector: "h5.title",
expectedText: "Custom Resources"
}]
}];
tests.forEach(({ drawer = "", drawerId = "", pages }) => {
if (drawer !== "") {
it(`shows ${drawer} drawer`, async () => {
expect(clusterAdded).toBe(true);
await app.client.click(`.sidebar-nav [data-test-id="${drawerId}"] span.link-text`);
await app.client.waitUntilTextExists(`a[href^="/${pages[0].href}"]`, pages[0].name);
});
}
pages.forEach(({ name, href, expectedSelector, expectedText }) => {
it(`shows ${drawer}->${name} page`, async () => {
expect(clusterAdded).toBe(true);
await app.client.click(`a[href^="/${href}"]`);
await app.client.waitUntilTextExists(expectedSelector, expectedText);
});
});
if (drawer !== "") {
// hide the drawer
it(`hides ${drawer} drawer`, async () => {
expect(clusterAdded).toBe(true);
await app.client.click(`.sidebar-nav [data-test-id="${drawerId}"] span.link-text`);
await expect(app.client.waitUntilTextExists(`a[href^="/${pages[0].href}"]`, pages[0].name, 100)).rejects.toThrow();
});
}
});
});
describe("viewing pod logs", () => {
beforeEach(appStartAddCluster, 40000);
afterEach(async () => {
if (app && app.isRunning()) {
return utils.tearDown(app);
}
});
it(`shows a logs for a pod`, async () => {
expect(clusterAdded).toBe(true);
// Go to Pods page
await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text");
await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods");
await app.client.click('a[href^="/pods"]');
await app.client.waitUntilTextExists("div.TableCell", "kube-apiserver");
// Open logs tab in dock
await app.client.click(".list .TableRow:first-child");
await app.client.waitForVisible(".Drawer");
await app.client.click(".drawer-title .Menu li:nth-child(2)");
// Check if controls are available
await app.client.waitForVisible(".Logs .VirtualList");
await app.client.waitForVisible(".LogResourceSelector");
await app.client.waitForVisible(".LogResourceSelector .SearchInput");
await app.client.waitForVisible(".LogResourceSelector .SearchInput input");
// Search for semicolon
await app.client.keys(":");
await app.client.waitForVisible(".Logs .list span.active");
// Click through controls
await app.client.click(".LogControls .show-timestamps");
await app.client.click(".LogControls .show-previous");
});
});
describe("cluster operations", () => {
beforeEach(appStartAddCluster, 40000);
afterEach(async () => {
if (app && app.isRunning()) {
return utils.tearDown(app);
}
});
it("shows default namespace", async () => {
expect(clusterAdded).toBe(true);
await app.client.click('a[href="/namespaces"]');
await app.client.waitUntilTextExists("div.TableCell", "default");
await app.client.waitUntilTextExists("div.TableCell", "kube-system");
});
it(`creates ${TEST_NAMESPACE} namespace`, async () => {
expect(clusterAdded).toBe(true);
await app.client.click('a[href="/namespaces"]');
await app.client.waitUntilTextExists("div.TableCell", "default");
await app.client.waitUntilTextExists("div.TableCell", "kube-system");
await app.client.click("button.add-button");
await app.client.waitUntilTextExists("div.AddNamespaceDialog", "Create Namespace");
await app.client.keys(`${TEST_NAMESPACE}\n`);
await app.client.waitForExist(`.name=${TEST_NAMESPACE}`);
});
it(`creates a pod in ${TEST_NAMESPACE} namespace`, async () => {
expect(clusterAdded).toBe(true);
await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text");
await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods");
await app.client.click('a[href^="/pods"]');
await app.client.waitUntilTextExists("div.TableCell", "kube-apiserver");
await app.client.click(".Icon.new-dock-tab");
await app.client.waitUntilTextExists("li.MenuItem.create-resource-tab", "Create resource");
await app.client.click("li.MenuItem.create-resource-tab");
await app.client.waitForVisible(".CreateResource div.ace_content");
// Write pod manifest to editor
await app.client.keys("apiVersion: v1\n");
await app.client.keys("kind: Pod\n");
await app.client.keys("metadata:\n");
await app.client.keys(" name: nginx-create-pod-test\n");
await app.client.keys(`namespace: ${TEST_NAMESPACE}\n`);
await app.client.keys(`${BACKSPACE}spec:\n`);
await app.client.keys(" containers:\n");
await app.client.keys("- name: nginx-create-pod-test\n");
await app.client.keys(" image: nginx:alpine\n");
// Create deployment
await app.client.waitForEnabled("button.Button=Create & Close");
await app.client.click("button.Button=Create & Close");
// Wait until first bits of pod appears on dashboard
await app.client.waitForExist(".name=nginx-create-pod-test");
// Open pod details
await app.client.click(".name=nginx-create-pod-test");
await app.client.waitUntilTextExists("div.drawer-title-text", "Pod: nginx-create-pod-test");
});
});
});
}); });

View File

@ -0,0 +1,450 @@
/*
Cluster tests are run if there is a pre-existing minikube cluster. Before running cluster tests the TEST_NAMESPACE
namespace is removed, if it exists, from the minikube cluster. Resources are created as part of the cluster tests in the
TEST_NAMESPACE namespace. This is done to minimize destructive impact of the cluster tests on an existing minikube
cluster and vice versa.
*/
import { Application } from "spectron";
import * as utils from "../helpers/utils";
import { addMinikubeCluster, minikubeReady, waitForMinikubeDashboard } from "../helpers/minikube";
import { exec } from "child_process";
import * as util from "util";
export const promiseExec = util.promisify(exec);
jest.setTimeout(60000);
// FIXME (!): improve / simplify all css-selectors + use [data-test-id="some-id"] (already used in some tests below)
describe("Lens cluster pages", () => {
const TEST_NAMESPACE = "integration-tests";
const BACKSPACE = "\uE003";
let app: Application;
const ready = minikubeReady(TEST_NAMESPACE);
utils.describeIf(ready)("test common pages", () => {
let clusterAdded = false;
const addCluster = async () => {
await utils.clickWhatsNew(app);
await addMinikubeCluster(app);
await waitForMinikubeDashboard(app);
await app.client.click('a[href="/nodes"]');
await app.client.waitUntilTextExists("div.TableCell", "Ready");
};
describe("cluster add", () => {
beforeAll(async () => app = await utils.appStart(), 20000);
afterAll(async () => {
if (app && app.isRunning()) {
return utils.tearDown(app);
}
});
it("allows to add a cluster", async () => {
await addCluster();
clusterAdded = true;
});
});
const appStartAddCluster = async () => {
if (clusterAdded) {
app = await utils.appStart();
await addCluster();
}
};
describe("cluster pages", () => {
beforeAll(appStartAddCluster, 40000);
afterAll(async () => {
if (app && app.isRunning()) {
return utils.tearDown(app);
}
});
const tests: {
drawer?: string
drawerId?: string
pages: {
name: string,
href: string,
expectedSelector: string,
expectedText: string
}[]
}[] = [{
drawer: "",
drawerId: "",
pages: [{
name: "Cluster",
href: "cluster",
expectedSelector: "div.ClusterOverview div.label",
expectedText: "Master"
}]
},
{
drawer: "",
drawerId: "",
pages: [{
name: "Nodes",
href: "nodes",
expectedSelector: "h5.title",
expectedText: "Nodes"
}]
},
{
drawer: "Workloads",
drawerId: "workloads",
pages: [{
name: "Overview",
href: "workloads",
expectedSelector: "h5.box",
expectedText: "Overview"
},
{
name: "Pods",
href: "pods",
expectedSelector: "h5.title",
expectedText: "Pods"
},
{
name: "Deployments",
href: "deployments",
expectedSelector: "h5.title",
expectedText: "Deployments"
},
{
name: "DaemonSets",
href: "daemonsets",
expectedSelector: "h5.title",
expectedText: "Daemon Sets"
},
{
name: "StatefulSets",
href: "statefulsets",
expectedSelector: "h5.title",
expectedText: "Stateful Sets"
},
{
name: "ReplicaSets",
href: "replicasets",
expectedSelector: "h5.title",
expectedText: "Replica Sets"
},
{
name: "Jobs",
href: "jobs",
expectedSelector: "h5.title",
expectedText: "Jobs"
},
{
name: "CronJobs",
href: "cronjobs",
expectedSelector: "h5.title",
expectedText: "Cron Jobs"
}]
},
{
drawer: "Configuration",
drawerId: "config",
pages: [{
name: "ConfigMaps",
href: "configmaps",
expectedSelector: "h5.title",
expectedText: "Config Maps"
},
{
name: "Secrets",
href: "secrets",
expectedSelector: "h5.title",
expectedText: "Secrets"
},
{
name: "Resource Quotas",
href: "resourcequotas",
expectedSelector: "h5.title",
expectedText: "Resource Quotas"
},
{
name: "Limit Ranges",
href: "limitranges",
expectedSelector: "h5.title",
expectedText: "Limit Ranges"
},
{
name: "HPA",
href: "hpa",
expectedSelector: "h5.title",
expectedText: "Horizontal Pod Autoscalers"
},
{
name: "Pod Disruption Budgets",
href: "poddisruptionbudgets",
expectedSelector: "h5.title",
expectedText: "Pod Disruption Budgets"
}]
},
{
drawer: "Network",
drawerId: "networks",
pages: [{
name: "Services",
href: "services",
expectedSelector: "h5.title",
expectedText: "Services"
},
{
name: "Endpoints",
href: "endpoints",
expectedSelector: "h5.title",
expectedText: "Endpoints"
},
{
name: "Ingresses",
href: "ingresses",
expectedSelector: "h5.title",
expectedText: "Ingresses"
},
{
name: "Network Policies",
href: "network-policies",
expectedSelector: "h5.title",
expectedText: "Network Policies"
}]
},
{
drawer: "Storage",
drawerId: "storage",
pages: [{
name: "Persistent Volume Claims",
href: "persistent-volume-claims",
expectedSelector: "h5.title",
expectedText: "Persistent Volume Claims"
},
{
name: "Persistent Volumes",
href: "persistent-volumes",
expectedSelector: "h5.title",
expectedText: "Persistent Volumes"
},
{
name: "Storage Classes",
href: "storage-classes",
expectedSelector: "h5.title",
expectedText: "Storage Classes"
}]
},
{
drawer: "",
drawerId: "",
pages: [{
name: "Namespaces",
href: "namespaces",
expectedSelector: "h5.title",
expectedText: "Namespaces"
}]
},
{
drawer: "",
drawerId: "",
pages: [{
name: "Events",
href: "events",
expectedSelector: "h5.title",
expectedText: "Events"
}]
},
{
drawer: "Apps",
drawerId: "apps",
pages: [{
name: "Charts",
href: "apps/charts",
expectedSelector: "div.HelmCharts input",
expectedText: ""
},
{
name: "Releases",
href: "apps/releases",
expectedSelector: "h5.title",
expectedText: "Releases"
}]
},
{
drawer: "Access Control",
drawerId: "users",
pages: [{
name: "Service Accounts",
href: "service-accounts",
expectedSelector: "h5.title",
expectedText: "Service Accounts"
},
{
name: "Role Bindings",
href: "role-bindings",
expectedSelector: "h5.title",
expectedText: "Role Bindings"
},
{
name: "Roles",
href: "roles",
expectedSelector: "h5.title",
expectedText: "Roles"
},
{
name: "Pod Security Policies",
href: "pod-security-policies",
expectedSelector: "h5.title",
expectedText: "Pod Security Policies"
}]
},
{
drawer: "Custom Resources",
drawerId: "custom-resources",
pages: [{
name: "Definitions",
href: "crd/definitions",
expectedSelector: "h5.title",
expectedText: "Custom Resources"
}]
}];
tests.forEach(({ drawer = "", drawerId = "", pages }) => {
if (drawer !== "") {
it(`shows ${drawer} drawer`, async () => {
expect(clusterAdded).toBe(true);
await app.client.click(`.sidebar-nav [data-test-id="${drawerId}"] span.link-text`);
await app.client.waitUntilTextExists(`a[href^="/${pages[0].href}"]`, pages[0].name);
});
}
pages.forEach(({ name, href, expectedSelector, expectedText }) => {
it(`shows ${drawer}->${name} page`, async () => {
expect(clusterAdded).toBe(true);
await app.client.click(`a[href^="/${href}"]`);
await app.client.waitUntilTextExists(expectedSelector, expectedText);
});
});
if (drawer !== "") {
// hide the drawer
it(`hides ${drawer} drawer`, async () => {
expect(clusterAdded).toBe(true);
await app.client.click(`.sidebar-nav [data-test-id="${drawerId}"] span.link-text`);
await expect(app.client.waitUntilTextExists(`a[href^="/${pages[0].href}"]`, pages[0].name, 100)).rejects.toThrow();
});
}
});
});
describe("viewing pod logs", () => {
beforeEach(appStartAddCluster, 40000);
afterEach(async () => {
if (app && app.isRunning()) {
return utils.tearDown(app);
}
});
it(`shows a logs for a pod`, async () => {
expect(clusterAdded).toBe(true);
// Go to Pods page
await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text");
await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods");
await app.client.click('a[href^="/pods"]');
await app.client.click(".NamespaceSelect");
await app.client.keys("kube-system");
await app.client.keys("Enter");// "\uE007"
await app.client.waitUntilTextExists("div.TableCell", "kube-apiserver");
let podMenuItemEnabled = false;
// Wait until extensions are enabled on renderer
while (!podMenuItemEnabled) {
const logs = await app.client.getRenderProcessLogs();
podMenuItemEnabled = !!logs.find(entry => entry.message.includes("[EXTENSION]: enabled lens-pod-menu@"));
if (!podMenuItemEnabled) {
await new Promise(r => setTimeout(r, 1000));
}
}
await new Promise(r => setTimeout(r, 500)); // Give some extra time to prepare extensions
// Open logs tab in dock
await app.client.click(".list .TableRow:first-child");
await app.client.waitForVisible(".Drawer");
await app.client.click(".drawer-title .Menu li:nth-child(2)");
// Check if controls are available
await app.client.waitForVisible(".LogList .VirtualList");
await app.client.waitForVisible(".LogResourceSelector");
//await app.client.waitForVisible(".LogSearch .SearchInput");
await app.client.waitForVisible(".LogSearch .SearchInput input");
// Search for semicolon
await app.client.keys(":");
await app.client.waitForVisible(".LogList .list span.active");
// Click through controls
await app.client.click(".LogControls .show-timestamps");
await app.client.click(".LogControls .show-previous");
});
});
describe("cluster operations", () => {
beforeEach(appStartAddCluster, 40000);
afterEach(async () => {
if (app && app.isRunning()) {
return utils.tearDown(app);
}
});
it("shows default namespace", async () => {
expect(clusterAdded).toBe(true);
await app.client.click('a[href="/namespaces"]');
await app.client.waitUntilTextExists("div.TableCell", "default");
await app.client.waitUntilTextExists("div.TableCell", "kube-system");
});
it(`creates ${TEST_NAMESPACE} namespace`, async () => {
expect(clusterAdded).toBe(true);
await app.client.click('a[href="/namespaces"]');
await app.client.waitUntilTextExists("div.TableCell", "default");
await app.client.waitUntilTextExists("div.TableCell", "kube-system");
await app.client.click("button.add-button");
await app.client.waitUntilTextExists("div.AddNamespaceDialog", "Create Namespace");
await app.client.keys(`${TEST_NAMESPACE}\n`);
await app.client.waitForExist(`.name=${TEST_NAMESPACE}`);
});
it(`creates a pod in ${TEST_NAMESPACE} namespace`, async () => {
expect(clusterAdded).toBe(true);
await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text");
await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods");
await app.client.click('a[href^="/pods"]');
await app.client.click(".NamespaceSelect");
await app.client.keys(TEST_NAMESPACE);
await app.client.keys("Enter");// "\uE007"
await app.client.click(".Icon.new-dock-tab");
await app.client.waitUntilTextExists("li.MenuItem.create-resource-tab", "Create resource");
await app.client.click("li.MenuItem.create-resource-tab");
await app.client.waitForVisible(".CreateResource div.ace_content");
// Write pod manifest to editor
await app.client.keys("apiVersion: v1\n");
await app.client.keys("kind: Pod\n");
await app.client.keys("metadata:\n");
await app.client.keys(" name: nginx-create-pod-test\n");
await app.client.keys(`namespace: ${TEST_NAMESPACE}\n`);
await app.client.keys(`${BACKSPACE}spec:\n`);
await app.client.keys(" containers:\n");
await app.client.keys("- name: nginx-create-pod-test\n");
await app.client.keys(" image: nginx:alpine\n");
// Create deployment
await app.client.waitForEnabled("button.Button=Create & Close");
await app.client.click("button.Button=Create & Close");
// Wait until first bits of pod appears on dashboard
await app.client.waitForExist(".name=nginx-create-pod-test");
// Open pod details
await app.client.click(".name=nginx-create-pod-test");
await app.client.waitUntilTextExists("div.drawer-title-text", "Pod: nginx-create-pod-test");
});
});
});
});

View File

@ -0,0 +1,25 @@
import { Application } from "spectron";
import * as utils from "../helpers/utils";
jest.setTimeout(60000);
describe("Lens command palette", () => {
let app: Application;
describe("menu", () => {
beforeAll(async () => app = await utils.appStart(), 20000);
afterAll(async () => {
if (app?.isRunning()) {
await utils.tearDown(app);
}
});
it("opens command dialog from menu", async () => {
await utils.clickWhatsNew(app);
await app.electron.ipcRenderer.send("test-menu-item-click", "View", "Command Palette...");
await app.client.waitUntilTextExists(".Select__option", "Preferences: Open");
await app.client.keys("Escape");
});
});
});

View File

@ -0,0 +1,75 @@
import { Application } from "spectron";
import * as utils from "../helpers/utils";
import { addMinikubeCluster, minikubeReady } from "../helpers/minikube";
import { exec } from "child_process";
import * as util from "util";
export const promiseExec = util.promisify(exec);
jest.setTimeout(60000);
describe("Lens integration tests", () => {
let app: Application;
const ready = minikubeReady("workspace-int-tests");
utils.describeIf(ready)("workspaces", () => {
beforeAll(async () => {
app = await utils.appStart();
await utils.clickWhatsNew(app);
}, 20000);
afterAll(async () => {
if (app && app.isRunning()) {
return utils.tearDown(app);
}
});
const switchToWorkspace = async (name: string) => {
await app.client.click("[data-test-id=current-workspace]");
await app.client.keys(name);
await app.client.keys("Enter");
await app.client.waitUntilTextExists("[data-test-id=current-workspace-name]", name);
};
const createWorkspace = async (name: string) => {
await app.client.click("[data-test-id=current-workspace]");
await app.client.keys("add workspace");
await app.client.keys("Enter");
await app.client.keys(name);
await app.client.keys("Enter");
await app.client.waitUntilTextExists("[data-test-id=current-workspace-name]", name);
};
it("creates new workspace", async () => {
const name = "test-workspace";
await createWorkspace(name);
await app.client.waitUntilTextExists("[data-test-id=current-workspace-name]", name);
});
it("edits current workspaces", async () => {
await createWorkspace("to-be-edited");
await app.client.click("[data-test-id=current-workspace]");
await app.client.keys("edit current workspace");
await app.client.keys("Enter");
await app.client.keys("edited-workspace");
await app.client.keys("Enter");
await app.client.waitUntilTextExists("[data-test-id=current-workspace-name]", "edited-workspace");
});
it("adds cluster in default workspace", async () => {
await switchToWorkspace("default");
await addMinikubeCluster(app);
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
await app.client.waitForExist(`iframe[name="minikube"]`);
await app.client.waitForVisible(".ClustersMenu .ClusterIcon.active");
});
it("adds cluster in test-workspace", async () => {
await switchToWorkspace("test-workspace");
await addMinikubeCluster(app);
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
await app.client.waitForExist(`iframe[name="minikube"]`);
});
});
});

View File

@ -0,0 +1,59 @@
import { spawnSync } from "child_process";
import { Application } from "spectron";
export function minikubeReady(testNamespace: string): boolean {
// determine if minikube is running
{
const { status } = spawnSync("minikube status", { shell: true });
if (status !== 0) {
console.warn("minikube not running");
return false;
}
}
// Remove TEST_NAMESPACE if it already exists
{
const { status } = spawnSync(`minikube kubectl -- get namespace ${testNamespace}`, { shell: true });
if (status === 0) {
console.warn(`Removing existing ${testNamespace} namespace`);
const { status, stdout, stderr } = spawnSync(
`minikube kubectl -- delete namespace ${testNamespace}`,
{ shell: true },
);
if (status !== 0) {
console.warn(`Error removing ${testNamespace} namespace: ${stderr.toString()}`);
return false;
}
console.log(stdout.toString());
}
}
return true;
}
export async function addMinikubeCluster(app: Application) {
await app.client.click("div.add-cluster");
await app.client.waitUntilTextExists("div", "Select kubeconfig file");
await app.client.click("div.Select__control"); // show the context drop-down list
await app.client.waitUntilTextExists("div", "minikube");
if (!await app.client.$("button.primary").isEnabled()) {
await app.client.click("div.minikube"); // select minikube context
} // else the only context, which must be 'minikube', is automatically selected
await app.client.click("div.Select__control"); // hide the context drop-down list (it might be obscuring the Add cluster(s) button)
await app.client.click("button.primary"); // add minikube cluster
}
export async function waitForMinikubeDashboard(app: Application) {
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
await app.client.waitForExist(`iframe[name="minikube"]`);
await app.client.frame("minikube");
await app.client.waitUntilTextExists("span.link-text", "Cluster");
}

View File

@ -1,4 +1,6 @@
import { Application } from "spectron"; import { Application } from "spectron";
import * as util from "util";
import { exec } from "child_process";
const AppPaths: Partial<Record<NodeJS.Platform, string>> = { const AppPaths: Partial<Record<NodeJS.Platform, string>> = {
"win32": "./dist/win-unpacked/Lens.exe", "win32": "./dist/win-unpacked/Lens.exe",
@ -26,6 +28,28 @@ export function setup(): Application {
}); });
} }
export const keys = {
backspace: "\uE003"
};
export async function appStart() {
const app = setup();
await app.start();
// Wait for splash screen to be closed
while (await app.client.getWindowCount() > 1);
await app.client.windowByIndex(0);
await app.client.waitUntilWindowLoaded();
return app;
}
export async function clickWhatsNew(app: Application) {
await app.client.waitUntilTextExists("h1", "What's new?");
await app.client.click("button.primary");
await app.client.waitUntilTextExists("h1", "Welcome");
}
type AsyncPidGetter = () => Promise<number>; type AsyncPidGetter = () => Promise<number>;
export async function tearDown(app: Application) { export async function tearDown(app: Application) {
@ -39,3 +63,26 @@ export async function tearDown(app: Application) {
console.error(e); console.error(e);
} }
} }
export const promiseExec = util.promisify(exec);
type HelmRepository = {
name: string;
url: string;
};
export async function listHelmRepositories(retries = 0): Promise<HelmRepository[]>{
if (retries < 5) {
try {
const { stdout: reposJson } = await promiseExec("helm repo list -o json");
return JSON.parse(reposJson);
} catch {
await new Promise(r => setTimeout(r, 2000)); // if no repositories, wait for Lens adding bitnami repository
return await listHelmRepositories((retries + 1));
}
}
return [];
}

View File

@ -2,7 +2,7 @@
"name": "kontena-lens", "name": "kontena-lens",
"productName": "Lens", "productName": "Lens",
"description": "Lens - The Kubernetes IDE", "description": "Lens - The Kubernetes IDE",
"version": "4.1.0-alpha.0", "version": "4.1.0-beta.2",
"main": "static/build/main.js", "main": "static/build/main.js",
"copyright": "© 2020, Mirantis, Inc.", "copyright": "© 2020, Mirantis, Inc.",
"license": "MIT", "license": "MIT",
@ -16,7 +16,7 @@
"dev-run": "nodemon --watch static/build/main.js --exec \"electron --inspect .\"", "dev-run": "nodemon --watch static/build/main.js --exec \"electron --inspect .\"",
"dev:main": "yarn run compile:main --watch", "dev:main": "yarn run compile:main --watch",
"dev:renderer": "yarn run webpack-dev-server --config webpack.renderer.ts", "dev:renderer": "yarn run webpack-dev-server --config webpack.renderer.ts",
"dev:extension-types": "yarn run compile:extension-types --watch", "dev:extension-types": "yarn run compile:extension-types --watch --progress",
"compile": "env NODE_ENV=production concurrently yarn:compile:*", "compile": "env NODE_ENV=production concurrently yarn:compile:*",
"compile:main": "yarn run webpack --config webpack.main.ts", "compile:main": "yarn run webpack --config webpack.main.ts",
"compile:renderer": "yarn run webpack --config webpack.renderer.ts", "compile:renderer": "yarn run webpack --config webpack.renderer.ts",
@ -26,7 +26,7 @@
"build:mac": "yarn run compile && electron-builder --mac --dir -c.productName=Lens", "build:mac": "yarn run compile && electron-builder --mac --dir -c.productName=Lens",
"build:win": "yarn run compile && electron-builder --win --dir -c.productName=Lens", "build:win": "yarn run compile && electron-builder --win --dir -c.productName=Lens",
"test": "jest --env=jsdom src $@", "test": "jest --env=jsdom src $@",
"integration": "jest --coverage integration $@", "integration": "jest --runInBand integration",
"dist": "yarn run compile && electron-builder --publish onTag", "dist": "yarn run compile && electron-builder --publish onTag",
"dist:win": "yarn run compile && electron-builder --publish onTag --x64 --ia32", "dist:win": "yarn run compile && electron-builder --publish onTag --x64 --ia32",
"dist:dir": "yarn run dist --dir -c.compression=store -c.mac.identity=null", "dist:dir": "yarn run dist --dir -c.compression=store -c.mac.identity=null",
@ -42,7 +42,7 @@
"typedocs-extensions-api": "yarn run typedoc --ignoreCompilerErrors --readme docs/extensions/typedoc-readme.md.tpl --name @k8slens/extensions --out docs/extensions/api --mode library --excludePrivate --hideBreadcrumbs --includes src/ src/extensions/extension-api.ts" "typedocs-extensions-api": "yarn run typedoc --ignoreCompilerErrors --readme docs/extensions/typedoc-readme.md.tpl --name @k8slens/extensions --out docs/extensions/api --mode library --excludePrivate --hideBreadcrumbs --includes src/ src/extensions/extension-api.ts"
}, },
"config": { "config": {
"bundledKubectlVersion": "1.17.15", "bundledKubectlVersion": "1.18.15",
"bundledHelmVersion": "3.4.2" "bundledHelmVersion": "3.4.2"
}, },
"engines": { "engines": {
@ -103,7 +103,10 @@
], ],
"linux": { "linux": {
"category": "Network", "category": "Network",
"artifactName": "${productName}-${version}.${arch}.${ext}",
"target": [ "target": [
"deb",
"rpm",
"snap", "snap",
"AppImage" "AppImage"
], ],
@ -154,7 +157,12 @@
] ]
}, },
"nsis": { "nsis": {
"include": "build/installer.nsh" "include": "build/installer.nsh",
"oneClick": false,
"allowToChangeInstallationDirectory": true
},
"snap": {
"confinement": "classic"
}, },
"publish": [ "publish": [
{ {
@ -162,10 +170,7 @@
"repo": "lens", "repo": "lens",
"owner": "lensapp" "owner": "lensapp"
} }
], ]
"snap": {
"confinement": "classic"
}
}, },
"lens": { "lens": {
"extensions": [ "extensions": [
@ -174,7 +179,8 @@
"node-menu", "node-menu",
"metrics-cluster-feature", "metrics-cluster-feature",
"license-menu-item", "license-menu-item",
"kube-object-event-status" "kube-object-event-status",
"survey"
] ]
}, },
"dependencies": { "dependencies": {
@ -183,6 +189,7 @@
"@kubernetes/client-node": "^0.12.0", "@kubernetes/client-node": "^0.12.0",
"array-move": "^3.0.0", "array-move": "^3.0.0",
"await-lock": "^2.1.0", "await-lock": "^2.1.0",
"byline": "^5.0.0",
"chalk": "^4.1.0", "chalk": "^4.1.0",
"chokidar": "^3.4.3", "chokidar": "^3.4.3",
"command-exists": "1.2.9", "command-exists": "1.2.9",
@ -200,7 +207,7 @@
"jsonpath": "^1.0.2", "jsonpath": "^1.0.2",
"lodash": "^4.17.15", "lodash": "^4.17.15",
"mac-ca": "^1.0.4", "mac-ca": "^1.0.4",
"marked": "^1.1.0", "marked": "^1.2.7",
"md5-file": "^5.0.0", "md5-file": "^5.0.0",
"mobx": "^5.15.7", "mobx": "^5.15.7",
"mobx-observable-history": "^1.0.3", "mobx-observable-history": "^1.0.3",
@ -215,12 +222,13 @@
"react": "^17.0.1", "react": "^17.0.1",
"react-dom": "^17.0.1", "react-dom": "^17.0.1",
"react-router": "^5.2.0", "react-router": "^5.2.0",
"readable-web-to-node-stream": "^3.0.1",
"request": "^2.88.2", "request": "^2.88.2",
"request-promise-native": "^1.0.8", "request-promise-native": "^1.0.8",
"selfsigned": "^1.10.8", "selfsigned": "^1.10.8",
"semver": "^7.3.2", "semver": "^7.3.2",
"serializr": "^2.0.3", "serializr": "^2.0.3",
"shell-env": "^3.0.0", "shell-env": "^3.0.1",
"spdy": "^4.0.2", "spdy": "^4.0.2",
"tar": "^6.0.5", "tar": "^6.0.5",
"tcp-port-used": "^1.0.1", "tcp-port-used": "^1.0.1",
@ -237,6 +245,7 @@
"@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
"@testing-library/jest-dom": "^5.11.5", "@testing-library/jest-dom": "^5.11.5",
"@testing-library/react": "^11.1.0", "@testing-library/react": "^11.1.0",
"@types/byline": "^4.2.32",
"@types/chart.js": "^2.9.21", "@types/chart.js": "^2.9.21",
"@types/circular-dependency-plugin": "^5.0.1", "@types/circular-dependency-plugin": "^5.0.1",
"@types/color": "^3.0.1", "@types/color": "^3.0.1",
@ -286,7 +295,7 @@
"@types/webpack-dev-server": "^3.11.1", "@types/webpack-dev-server": "^3.11.1",
"@types/webpack-env": "^1.15.2", "@types/webpack-env": "^1.15.2",
"@types/webpack-node-externals": "^1.7.1", "@types/webpack-node-externals": "^1.7.1",
"@typescript-eslint/eslint-plugin": "^4.12.0", "@typescript-eslint/eslint-plugin": "^4.14.2",
"@typescript-eslint/parser": "^4.0.0", "@typescript-eslint/parser": "^4.0.0",
"ace-builds": "^1.4.11", "ace-builds": "^1.4.11",
"ansi_up": "^4.0.4", "ansi_up": "^4.0.4",
@ -314,7 +323,7 @@
"jest-canvas-mock": "^2.3.0", "jest-canvas-mock": "^2.3.0",
"jest-fetch-mock": "^3.0.3", "jest-fetch-mock": "^3.0.3",
"jest-mock-extended": "^1.0.10", "jest-mock-extended": "^1.0.10",
"make-plural": "^6.2.1", "make-plural": "^6.2.2",
"mini-css-extract-plugin": "^0.9.0", "mini-css-extract-plugin": "^0.9.0",
"moment": "^2.26.0", "moment": "^2.26.0",
"node-loader": "^0.6.0", "node-loader": "^0.6.0",
@ -329,6 +338,7 @@
"react-refresh": "^0.9.0", "react-refresh": "^0.9.0",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-select": "^3.1.0", "react-select": "^3.1.0",
"react-select-event": "^5.1.0",
"react-window": "^1.8.5", "react-window": "^1.8.5",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"sharp": "^0.26.1", "sharp": "^0.26.1",

View File

@ -0,0 +1,126 @@
import { EventEmitter } from "events";
import { onCorrect, onceCorrect } from "../type-enforced-ipc";
describe("type enforced ipc tests", () => {
describe("onCorrect tests", () => {
it("should call the handler if the args are valid", () => {
let called = false;
const source = new EventEmitter();
const listener = () => called = true;
const verifier = (args: unknown[]): args is [] => true;
const channel = "foobar";
onCorrect({ source, listener, verifier, channel });
source.emit(channel);
expect(called).toBe(true);
});
it("should not call the handler if the args are not valid", () => {
let called = false;
const source = new EventEmitter();
const listener = () => called = true;
const verifier = (args: unknown[]): args is [] => false;
const channel = "foobar";
onCorrect({ source, listener, verifier, channel });
source.emit(channel);
expect(called).toBe(false);
});
it("should call the handler twice if the args are valid on two emits", () => {
let called = 0;
const source = new EventEmitter();
const listener = () => called += 1;
const verifier = (args: unknown[]): args is [] => true;
const channel = "foobar";
onCorrect({ source, listener, verifier, channel });
source.emit(channel);
source.emit(channel);
expect(called).toBe(2);
});
it("should call the handler twice if the args are [valid, invalid, valid]", () => {
let called = 0;
const source = new EventEmitter();
const listener = () => called += 1;
const results = [true, false, true];
const verifier = (args: unknown[]): args is [] => results.pop();
const channel = "foobar";
onCorrect({ source, listener, verifier, channel });
source.emit(channel);
source.emit(channel);
source.emit(channel);
expect(called).toBe(2);
});
});
describe("onceCorrect tests", () => {
it("should call the handler if the args are valid", () => {
let called = false;
const source = new EventEmitter();
const listener = () => called = true;
const verifier = (args: unknown[]): args is [] => true;
const channel = "foobar";
onceCorrect({ source, listener, verifier, channel });
source.emit(channel);
expect(called).toBe(true);
});
it("should not call the handler if the args are not valid", () => {
let called = false;
const source = new EventEmitter();
const listener = () => called = true;
const verifier = (args: unknown[]): args is [] => false;
const channel = "foobar";
onceCorrect({ source, listener, verifier, channel });
source.emit(channel);
expect(called).toBe(false);
});
it("should call the handler only once even if args are valid multiple times", () => {
let called = 0;
const source = new EventEmitter();
const listener = () => called += 1;
const verifier = (args: unknown[]): args is [] => true;
const channel = "foobar";
onceCorrect({ source, listener, verifier, channel });
source.emit(channel);
source.emit(channel);
expect(called).toBe(1);
});
it("should call the handler on only the first valid set of args", () => {
let called = "";
let verifierCalled = 0;
const source = new EventEmitter();
const listener = (info: any, arg: string) => called = arg;
const verifier = (args: unknown[]): args is [string] => (++verifierCalled) % 3 === 0;
const channel = "foobar";
onceCorrect({ source, listener, verifier, channel });
source.emit(channel, {}, "a");
source.emit(channel, {}, "b");
source.emit(channel, {}, "c");
source.emit(channel, {}, "d");
source.emit(channel, {}, "e");
source.emit(channel, {}, "f");
source.emit(channel, {}, "g");
source.emit(channel, {}, "h");
source.emit(channel, {}, "i");
expect(called).toBe("c");
});
});
});

3
src/common/ipc/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from "./ipc";
export * from "./update-available";
export * from "./type-enforced-ipc";

View File

@ -3,10 +3,13 @@
// https://www.electronjs.org/docs/api/ipc-renderer // https://www.electronjs.org/docs/api/ipc-renderer
import { ipcMain, ipcRenderer, webContents, remote } from "electron"; import { ipcMain, ipcRenderer, webContents, remote } from "electron";
import logger from "../main/logger"; import { toJS } from "mobx";
import { ClusterFrameInfo, clusterFrameMap } from "./cluster-frames"; import logger from "../../main/logger";
import { ClusterFrameInfo, clusterFrameMap } from "../cluster-frames";
export function handleRequest(channel: string, listener: (...args: any[]) => any) { const subFramesChannel = "ipc:get-sub-frames";
export function handleRequest(channel: string, listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any) {
ipcMain.handle(channel, listener); ipcMain.handle(channel, listener);
} }
@ -14,38 +17,39 @@ export async function requestMain(channel: string, ...args: any[]) {
return ipcRenderer.invoke(channel, ...args); return ipcRenderer.invoke(channel, ...args);
} }
async function getSubFrames(): Promise<ClusterFrameInfo[]> { function getSubFrames(): ClusterFrameInfo[] {
const subFrames: ClusterFrameInfo[] = []; return toJS(Array.from(clusterFrameMap.values()), { recurseEverything: true });
clusterFrameMap.forEach(frameInfo => {
subFrames.push(frameInfo);
});
return subFrames;
} }
export function broadcastMessage(channel: string, ...args: any[]) { export async function broadcastMessage(channel: string, ...args: any[]) {
const views = (webContents || remote?.webContents)?.getAllWebContents(); const views = (webContents || remote?.webContents)?.getAllWebContents();
if (!views) return; if (!views) return;
views.forEach(webContent => {
const type = webContent.getType();
logger.silly(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args });
webContent.send(channel, ...args);
getSubFrames().then((frames) => {
frames.map((frameInfo) => {
webContent.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args);
});
}).catch((e) => e);
});
if (ipcRenderer) { if (ipcRenderer) {
ipcRenderer.send(channel, ...args); ipcRenderer.send(channel, ...args);
} else { } else {
ipcMain.emit(channel, ...args); ipcMain.emit(channel, ...args);
} }
for (const view of views) {
const type = view.getType();
logger.silly(`[IPC]: broadcasting "${channel}" to ${type}=${view.id}`, { args });
view.send(channel, ...args);
try {
const subFrames: ClusterFrameInfo[] = ipcRenderer
? await requestMain(subFramesChannel)
: getSubFrames();
for (const frameInfo of subFrames) {
view.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args);
}
} catch (error) {
logger.error("[IPC]: failed to send IPC message", { error });
}
}
} }
export function subscribeToBroadcast(channel: string, listener: (...args: any[]) => any) { export function subscribeToBroadcast(channel: string, listener: (...args: any[]) => any) {
@ -73,3 +77,9 @@ export function unsubscribeAllFromBroadcast(channel: string) {
ipcMain.removeAllListeners(channel); ipcMain.removeAllListeners(channel);
} }
} }
export function bindBroadcastHandlers() {
handleRequest(subFramesChannel, () => {
return getSubFrames();
});
}

View File

@ -0,0 +1,71 @@
import { EventEmitter } from "events";
import logger from "../../main/logger";
export type HandlerEvent<EM extends EventEmitter> = Parameters<Parameters<EM["on"]>[1]>[0];
export type ListVerifier<T extends any[]> = (args: unknown[]) => args is T;
export type Rest<T> = T extends [any, ...infer R] ? R : [];
/**
* Adds a listener to `source` that waits for the first IPC message with the correct
* argument data is sent.
* @param channel The channel to be listened on
* @param listener The function for the channel to be called if the args of the correct type
* @param verifier The function to be called to verify that the args are the correct type
*/
export function onceCorrect<
EM extends EventEmitter,
L extends (event: HandlerEvent<EM>, ...args: any[]) => any
>({
source,
channel,
listener,
verifier,
}: {
source: EM,
channel: string | symbol,
listener: L,
verifier: ListVerifier<Rest<Parameters<L>>>,
}): void {
function handler(event: HandlerEvent<EM>, ...args: unknown[]): void {
if (verifier(args)) {
source.removeListener(channel, handler); // remove immediately
(async () => (listener(event, ...args)))() // might return a promise, or throw, or reject
.catch((error: any) => logger.error("[IPC]: channel once handler threw error", { channel, error }));
} else {
logger.error("[IPC]: channel was emitted with invalid data", { channel, args });
}
}
source.on(channel, handler);
}
/**
* Adds a listener to `source` that checks to verify the arguments before calling the handler.
* @param channel The channel to be listened on
* @param listener The function for the channel to be called if the args of the correct type
* @param verifier The function to be called to verify that the args are the correct type
*/
export function onCorrect<
EM extends EventEmitter,
L extends (event: HandlerEvent<EM>, ...args: any[]) => any
>({
source,
channel,
listener,
verifier,
}: {
source: EM,
channel: string | symbol,
listener: L,
verifier: ListVerifier<Rest<Parameters<L>>>,
}): void {
source.on(channel, (event, ...args: unknown[]) => {
if (verifier(args)) {
(async () => (listener(event, ...args)))() // might return a promise, or throw, or reject
.catch(error => logger.error("[IPC]: channel on handler threw error", { channel, error }));
} else {
logger.error("[IPC]: channel was emitted with invalid data", { channel, args });
}
});
}

View File

@ -0,0 +1,48 @@
import { UpdateInfo } from "electron-updater";
export const UpdateAvailableChannel = "update-available";
export const AutoUpdateLogPrefix = "[UPDATE-CHECKER]";
/**
* [<back-channel>, <update-info>]
*/
export type UpdateAvailableFromMain = [string, UpdateInfo];
export function areArgsUpdateAvailableFromMain(args: unknown[]): args is UpdateAvailableFromMain {
if (args.length !== 2) {
return false;
}
if (typeof args[0] !== "string") {
return false;
}
if (typeof args[1] !== "object" || args[1] === null) {
// TODO: improve this checking
return false;
}
return true;
}
export type BackchannelArg = {
doUpdate: false;
} | {
doUpdate: true;
now: boolean;
};
export type UpdateAvailableToBackchannel = [BackchannelArg];
export function areArgsUpdateAvailableToBackchannel(args: unknown[]): args is UpdateAvailableToBackchannel {
if (args.length !== 1) {
return false;
}
if (typeof args[0] !== "object" || args[0] === null) {
// TODO: improve this checking
return false;
}
return true;
}

View File

@ -7,37 +7,38 @@ export type KubeResource =
"endpoints" | "customresourcedefinitions" | "horizontalpodautoscalers" | "podsecuritypolicies" | "poddisruptionbudgets"; "endpoints" | "customresourcedefinitions" | "horizontalpodautoscalers" | "podsecuritypolicies" | "poddisruptionbudgets";
export interface KubeApiResource { export interface KubeApiResource {
resource: KubeResource; // valid resource name kind: string; // resource type (e.g. "Namespace")
apiName: KubeResource; // valid api resource name (e.g. "namespaces")
group?: string; // api-group group?: string; // api-group
} }
// TODO: auto-populate all resources dynamically (see: kubectl api-resources -o=wide -v=7) // TODO: auto-populate all resources dynamically (see: kubectl api-resources -o=wide -v=7)
export const apiResources: KubeApiResource[] = [ export const apiResources: KubeApiResource[] = [
{ resource: "configmaps" }, { kind: "ConfigMap", apiName: "configmaps" },
{ resource: "cronjobs", group: "batch" }, { kind: "CronJob", apiName: "cronjobs", group: "batch" },
{ resource: "customresourcedefinitions", group: "apiextensions.k8s.io" }, { kind: "CustomResourceDefinition", apiName: "customresourcedefinitions", group: "apiextensions.k8s.io" },
{ resource: "daemonsets", group: "apps" }, { kind: "DaemonSet", apiName: "daemonsets", group: "apps" },
{ resource: "deployments", group: "apps" }, { kind: "Deployment", apiName: "deployments", group: "apps" },
{ resource: "endpoints" }, { kind: "Endpoint", apiName: "endpoints" },
{ resource: "events" }, { kind: "Event", apiName: "events" },
{ resource: "horizontalpodautoscalers" }, { kind: "HorizontalPodAutoscaler", apiName: "horizontalpodautoscalers" },
{ resource: "ingresses", group: "networking.k8s.io" }, { kind: "Ingress", apiName: "ingresses", group: "networking.k8s.io" },
{ resource: "jobs", group: "batch" }, { kind: "Job", apiName: "jobs", group: "batch" },
{ resource: "limitranges" }, { kind: "Namespace", apiName: "namespaces" },
{ resource: "namespaces" }, { kind: "LimitRange", apiName: "limitranges" },
{ resource: "networkpolicies", group: "networking.k8s.io" }, { kind: "NetworkPolicy", apiName: "networkpolicies", group: "networking.k8s.io" },
{ resource: "nodes" }, { kind: "Node", apiName: "nodes" },
{ resource: "persistentvolumes" }, { kind: "PersistentVolume", apiName: "persistentvolumes" },
{ resource: "persistentvolumeclaims" }, { kind: "PersistentVolumeClaim", apiName: "persistentvolumeclaims" },
{ resource: "pods" }, { kind: "Pod", apiName: "pods" },
{ resource: "poddisruptionbudgets" }, { kind: "PodDisruptionBudget", apiName: "poddisruptionbudgets" },
{ resource: "podsecuritypolicies" }, { kind: "PodSecurityPolicy", apiName: "podsecuritypolicies" },
{ resource: "resourcequotas" }, { kind: "ResourceQuota", apiName: "resourcequotas" },
{ resource: "replicasets", group: "apps" }, { kind: "ReplicaSet", apiName: "replicasets", group: "apps" },
{ resource: "secrets" }, { kind: "Secret", apiName: "secrets" },
{ resource: "services" }, { kind: "Service", apiName: "services" },
{ resource: "statefulsets", group: "apps" }, { kind: "StatefulSet", apiName: "statefulsets", group: "apps" },
{ resource: "storageclasses", group: "storage.k8s.io" }, { kind: "StorageClass", apiName: "storageclasses", group: "storage.k8s.io" },
]; ];
export function isAllowedResource(resources: KubeResource | KubeResource[]) { export function isAllowedResource(resources: KubeResource | KubeResource[]) {

View File

@ -1,4 +1,5 @@
import { action, computed, observable } from "mobx"; import { action, computed, observable,reaction } from "mobx";
import { dockStore } from "../renderer/components/dock/dock.store";
import { autobind } from "../renderer/utils"; import { autobind } from "../renderer/utils";
export class SearchStore { export class SearchStore {
@ -6,6 +7,12 @@ export class SearchStore {
@observable occurrences: number[] = []; // Array with line numbers, eg [0, 0, 10, 21, 21, 40...] @observable occurrences: number[] = []; // Array with line numbers, eg [0, 0, 10, 21, 21, 40...]
@observable activeOverlayIndex = -1; // Index withing the occurences array. Showing where is activeOverlay currently located @observable activeOverlayIndex = -1; // Index withing the occurences array. Showing where is activeOverlay currently located
constructor() {
reaction(() => dockStore.selectedTabId, () => {
searchStore.reset();
});
}
/** /**
* Sets default activeOverlayIndex * Sets default activeOverlayIndex
* @param text An array of any textual data (logs, for example) * @param text An array of any textual data (logs, for example)

View File

@ -84,6 +84,15 @@ export class UserStore extends BaseStore<UserStoreModel> {
return semver.gt(getAppVersion(), this.lastSeenAppVersion); return semver.gt(getAppVersion(), this.lastSeenAppVersion);
} }
@action
setHiddenTableColumns(tableId: string, names: Set<string> | string[]) {
this.preferences.hiddenTableColumns[tableId] = Array.from(names);
}
getHiddenTableColumns(tableId: string): Set<string> {
return new Set(this.preferences.hiddenTableColumns[tableId]);
}
@action @action
resetKubeConfigPath() { resetKubeConfigPath() {
this.kubeConfigPath = kubeConfigDefaultPath; this.kubeConfigPath = kubeConfigDefaultPath;

View File

@ -1,3 +1,4 @@
import requestPromise from "request-promise-native";
import packageInfo from "../../../package.json"; import packageInfo from "../../../package.json";
export function getAppVersion(): string { export function getAppVersion(): string {
@ -11,3 +12,13 @@ export function getBundledKubectlVersion(): string {
export function getBundledExtensions(): string[] { export function getBundledExtensions(): string[] {
return packageInfo.lens?.extensions || []; return packageInfo.lens?.extensions || [];
} }
export async function getAppVersionFromProxyServer(proxyPort: number): Promise<string> {
const response = await requestPromise({
method: "GET",
uri: `http://localhost:${proxyPort}/version`,
resolveWithFullResponse: true
});
return JSON.parse(response.body).version;
}

View File

@ -0,0 +1,8 @@
/**
* Return a promise that will be resolved after at least `timeout` ms have
* passed
* @param timeout The number of milliseconds before resolving
*/
export function delay(timeout = 1000): Promise<void> {
return new Promise(resolve => setTimeout(resolve, timeout));
}

View File

@ -7,6 +7,7 @@ export * from "./autobind";
export * from "./base64"; export * from "./base64";
export * from "./camelCase"; export * from "./camelCase";
export * from "./cloneJson"; export * from "./cloneJson";
export * from "./delay";
export * from "./debouncePromise"; export * from "./debouncePromise";
export * from "./defineGlobal"; export * from "./defineGlobal";
export * from "./getRandId"; export * from "./getRandId";
@ -17,3 +18,4 @@ export * from "./openExternal";
export * from "./downloadFile"; export * from "./downloadFile";
export * from "./escapeRegExp"; export * from "./escapeRegExp";
export * from "./tar"; export * from "./tar";
export * from "./delay";

View File

@ -2,6 +2,7 @@ import type { AppPreferenceRegistration, ClusterFeatureRegistration, ClusterPage
import type { Cluster } from "../main/cluster"; import type { Cluster } from "../main/cluster";
import { LensExtension } from "./lens-extension"; import { LensExtension } from "./lens-extension";
import { getExtensionPageUrl } from "./registries/page-registry"; import { getExtensionPageUrl } from "./registries/page-registry";
import { CommandRegistration } from "./registries/command-registry";
export class LensRendererExtension extends LensExtension { export class LensRendererExtension extends LensExtension {
globalPages: PageRegistration[] = []; globalPages: PageRegistration[] = [];
@ -14,6 +15,7 @@ export class LensRendererExtension extends LensExtension {
statusBarItems: StatusBarRegistration[] = []; statusBarItems: StatusBarRegistration[] = [];
kubeObjectDetailItems: KubeObjectDetailRegistration[] = []; kubeObjectDetailItems: KubeObjectDetailRegistration[] = [];
kubeObjectMenuItems: KubeObjectMenuRegistration[] = []; kubeObjectMenuItems: KubeObjectMenuRegistration[] = [];
commands: CommandRegistration[] = [];
async navigate<P extends object>(pageId?: string, params?: P) { async navigate<P extends object>(pageId?: string, params?: P) {
const { navigate } = await import("../renderer/navigation"); const { navigate } = await import("../renderer/navigation");

View File

@ -0,0 +1,37 @@
// Extensions API -> Commands
import type { Cluster } from "../../main/cluster";
import type { Workspace } from "../../common/workspace-store";
import { BaseRegistry } from "./base-registry";
import { action } from "mobx";
import { LensExtension } from "../lens-extension";
export type CommandContext = {
cluster?: Cluster;
workspace?: Workspace;
};
export interface CommandRegistration {
id: string;
title: string;
scope: "cluster" | "global";
action: (context: CommandContext) => void;
isActive?: (context: CommandContext) => boolean;
}
export class CommandRegistry extends BaseRegistry<CommandRegistration> {
@action
add(items: CommandRegistration | CommandRegistration[], extension?: LensExtension) {
const itemArray = [items].flat();
const newIds = itemArray.map((item) => item.id);
const currentIds = this.getItems().map((item) => item.id);
const filteredIds = newIds.filter((id) => !currentIds.includes(id));
const filteredItems = itemArray.filter((item) => filteredIds.includes(item.id));
return super.add(filteredItems, extension);
}
}
export const commandRegistry = new CommandRegistry();

View File

@ -3,7 +3,18 @@
import React from "react"; import React from "react";
import { BaseRegistry } from "./base-registry"; import { BaseRegistry } from "./base-registry";
export interface StatusBarRegistration { interface StatusBarComponents {
Item?: React.ComponentType;
}
interface StatusBarRegistrationV2 {
components: StatusBarComponents;
}
export interface StatusBarRegistration extends StatusBarRegistrationV2 {
/**
* @deprecated use components.Item instead
*/
item?: React.ReactNode; item?: React.ReactNode;
} }

View File

@ -13,6 +13,9 @@ export * from "../../renderer/components/select";
export * from "../../renderer/components/slider"; export * from "../../renderer/components/slider";
export * from "../../renderer/components/input/input"; export * from "../../renderer/components/input/input";
// command-overlay
export { CommandOverlay } from "../../renderer/components/command-palette";
// other components // other components
export * from "../../renderer/components/icon"; export * from "../../renderer/components/icon";
export * from "../../renderer/components/tooltip"; export * from "../../renderer/components/tooltip";
@ -38,4 +41,4 @@ export * from "../../renderer/components/+events/kube-event-details";
// specific exports // specific exports
export * from "../../renderer/components/status-brick"; export * from "../../renderer/components/status-brick";
export { terminalStore, createTerminalTab } from "../../renderer/components/dock/terminal.store"; export { terminalStore, createTerminalTab } from "../../renderer/components/dock/terminal.store";
export { createPodLogsTab } from "../../renderer/components/dock/log.store"; export { logTabStore } from "../../renderer/components/dock/log-tab.store";

View File

@ -126,6 +126,7 @@ describe("create clusters", () => {
}; };
jest.spyOn(Cluster.prototype, "isClusterAdmin").mockReturnValue(Promise.resolve(true)); jest.spyOn(Cluster.prototype, "isClusterAdmin").mockReturnValue(Promise.resolve(true));
jest.spyOn(Cluster.prototype, "canUseWatchApi").mockReturnValue(Promise.resolve(true));
jest.spyOn(Cluster.prototype, "canI") jest.spyOn(Cluster.prototype, "canI")
.mockImplementation((attr: V1ResourceAttributes): Promise<boolean> => { .mockImplementation((attr: V1ResourceAttributes): Promise<boolean> => {
expect(attr.namespace).toBe("default"); expect(attr.namespace).toBe("default");

View File

@ -1,20 +1,78 @@
import { autoUpdater } from "electron-updater"; import { autoUpdater, UpdateInfo } from "electron-updater";
import logger from "./logger"; import logger from "./logger";
import { isDevelopment, isTestEnv } from "../common/vars";
import { delay } from "../common/utils";
import { areArgsUpdateAvailableToBackchannel, AutoUpdateLogPrefix, broadcastMessage, onceCorrect, UpdateAvailableChannel, UpdateAvailableToBackchannel } from "../common/ipc";
import { ipcMain } from "electron";
export class AppUpdater { function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: UpdateAvailableToBackchannel) {
static readonly defaultUpdateIntervalMs = 1000 * 60 * 60 * 24; // once a day if (arg.doUpdate) {
if (arg.now) {
static checkForUpdates() { logger.info(`${AutoUpdateLogPrefix}: User chose to update now`);
return autoUpdater.checkForUpdatesAndNotify(); autoUpdater.downloadUpdate()
} .then(() => autoUpdater.quitAndInstall())
.catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download or install update`, { error }));
constructor(protected updateInterval = AppUpdater.defaultUpdateIntervalMs) { } else {
autoUpdater.logger = logger; logger.info(`${AutoUpdateLogPrefix}: User chose to update on quit`);
} autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.downloadUpdate()
public start() { .catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download update`, { error }));
setInterval(AppUpdater.checkForUpdates, this.updateInterval); }
} else {
return AppUpdater.checkForUpdates(); logger.info(`${AutoUpdateLogPrefix}: User chose not to update`);
}
}
/**
* starts the automatic update checking
* @param interval milliseconds between interval to check on, defaults to 24h
*/
export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void {
if (isDevelopment || isTestEnv) {
return;
}
autoUpdater.logger = logger;
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater
.on("update-available", (args: UpdateInfo) => {
try {
const backchannel = `auto-update:${args.version}`;
ipcMain.removeAllListeners(backchannel); // only one handler should be present
// make sure that the handler is in place before broadcasting (prevent race-condition)
onceCorrect({
source: ipcMain,
channel: backchannel,
listener: handleAutoUpdateBackChannel,
verifier: areArgsUpdateAvailableToBackchannel,
});
logger.info(`${AutoUpdateLogPrefix}: broadcasting update available`, { backchannel, version: args.version });
broadcastMessage(UpdateAvailableChannel, backchannel, args);
} catch (error) {
logger.error(`${AutoUpdateLogPrefix}: broadcasting failed`, { error });
}
});
async function helper() {
while (true) {
await checkForUpdates();
await delay(interval);
}
}
helper();
}
export async function checkForUpdates(): Promise<void> {
try {
logger.info(`📡 Checking for app updates`);
await autoUpdater.checkForUpdates();
} catch (error) {
logger.error(`${AutoUpdateLogPrefix}: failed with an error`, { error: String(error) });
} }
} }

View File

@ -1,7 +1,7 @@
import "../common/cluster-ipc"; import "../common/cluster-ipc";
import type http from "http"; import type http from "http";
import { ipcMain } from "electron"; import { ipcMain } from "electron";
import { autorun } from "mobx"; import { autorun, reaction } from "mobx";
import { clusterStore, getClusterIdFromHost } from "../common/cluster-store"; import { clusterStore, getClusterIdFromHost } from "../common/cluster-store";
import { Cluster } from "./cluster"; import { Cluster } from "./cluster";
import logger from "./logger"; import logger from "./logger";
@ -12,14 +12,14 @@ export class ClusterManager extends Singleton {
constructor(public readonly port: number) { constructor(public readonly port: number) {
super(); super();
// auto-init clusters // auto-init clusters
autorun(() => { reaction(() => clusterStore.enabledClustersList, (clusters) => {
clusterStore.enabledClustersList.forEach(cluster => { clusters.forEach((cluster) => {
if (!cluster.initialized && !cluster.initializing) { if (!cluster.initialized && !cluster.initializing) {
logger.info(`[CLUSTER-MANAGER]: init cluster`, cluster.getMeta()); logger.info(`[CLUSTER-MANAGER]: init cluster`, cluster.getMeta());
cluster.init(port); cluster.init(port);
} }
}); });
}); }, { fireImmediately: true });
// auto-stop removed clusters // auto-stop removed clusters
autorun(() => { autorun(() => {

View File

@ -49,6 +49,7 @@ export interface ClusterState {
isAdmin: boolean; isAdmin: boolean;
allowedNamespaces: string[] allowedNamespaces: string[]
allowedResources: string[] allowedResources: string[]
isGlobalWatchEnabled: boolean;
} }
/** /**
@ -92,7 +93,6 @@ export class Cluster implements ClusterModel, ClusterState {
*/ */
@observable initializing = false; @observable initializing = false;
/** /**
* Is cluster object initialized * Is cluster object initialized
* *
@ -178,6 +178,12 @@ export class Cluster implements ClusterModel, ClusterState {
* @observable * @observable
*/ */
@observable isAdmin = false; @observable isAdmin = false;
/**
* Global watch-api accessibility , e.g. "/api/v1/services?watch=1"
*
* @observable
*/
@observable isGlobalWatchEnabled = false;
/** /**
* Preferences * Preferences
* *
@ -191,7 +197,7 @@ export class Cluster implements ClusterModel, ClusterState {
*/ */
@observable metadata: ClusterMetadata = {}; @observable metadata: ClusterMetadata = {};
/** /**
* List of allowed namespaces * List of allowed namespaces verified via K8S::SelfSubjectAccessReview api
* *
* @observable * @observable
*/ */
@ -204,7 +210,7 @@ export class Cluster implements ClusterModel, ClusterState {
*/ */
@observable allowedResources: string[] = []; @observable allowedResources: string[] = [];
/** /**
* List of accessible namespaces * List of accessible namespaces provided by user in the Cluster Settings
* *
* @observable * @observable
*/ */
@ -225,7 +231,7 @@ export class Cluster implements ClusterModel, ClusterState {
* @computed * @computed
*/ */
@computed get name() { @computed get name() {
return this.preferences.clusterName || this.contextName; return this.preferences.clusterName || this.contextName;
} }
/** /**
@ -280,7 +286,8 @@ export class Cluster implements ClusterModel, ClusterState {
* @param port port where internal auth proxy is listening * @param port port where internal auth proxy is listening
* @internal * @internal
*/ */
@action async init(port: number) { @action
async init(port: number) {
try { try {
this.initializing = true; this.initializing = true;
this.contextHandler = new ContextHandler(this); this.contextHandler = new ContextHandler(this);
@ -335,7 +342,8 @@ export class Cluster implements ClusterModel, ClusterState {
* @param force force activation * @param force force activation
* @internal * @internal
*/ */
@action async activate(force = false) { @action
async activate(force = false) {
if (this.activated && !force) { if (this.activated && !force) {
return this.pushState(); return this.pushState();
} }
@ -352,9 +360,7 @@ export class Cluster implements ClusterModel, ClusterState {
await this.refreshConnectionStatus(); await this.refreshConnectionStatus();
if (this.accessible) { if (this.accessible) {
await this.refreshAllowedResources(); await this.refreshAccessibility();
this.isAdmin = await this.isClusterAdmin();
this.ready = true;
this.ensureKubectl(); this.ensureKubectl();
} }
this.activated = true; this.activated = true;
@ -374,7 +380,8 @@ export class Cluster implements ClusterModel, ClusterState {
/** /**
* @internal * @internal
*/ */
@action async reconnect() { @action
async reconnect() {
logger.info(`[CLUSTER]: reconnect`, this.getMeta()); logger.info(`[CLUSTER]: reconnect`, this.getMeta());
this.contextHandler?.stopServer(); this.contextHandler?.stopServer();
await this.contextHandler?.ensureServer(); await this.contextHandler?.ensureServer();
@ -401,19 +408,18 @@ export class Cluster implements ClusterModel, ClusterState {
* @internal * @internal
* @param opts refresh options * @param opts refresh options
*/ */
@action async refresh(opts: ClusterRefreshOptions = {}) { @action
async refresh(opts: ClusterRefreshOptions = {}) {
logger.info(`[CLUSTER]: refresh`, this.getMeta()); logger.info(`[CLUSTER]: refresh`, this.getMeta());
await this.whenInitialized; await this.whenInitialized;
await this.refreshConnectionStatus(); await this.refreshConnectionStatus();
if (this.accessible) { if (this.accessible) {
this.isAdmin = await this.isClusterAdmin(); await this.refreshAccessibility();
await this.refreshAllowedResources();
if (opts.refreshMetadata) { if (opts.refreshMetadata) {
this.refreshMetadata(); this.refreshMetadata();
} }
this.ready = true;
} }
this.pushState(); this.pushState();
} }
@ -421,7 +427,8 @@ export class Cluster implements ClusterModel, ClusterState {
/** /**
* @internal * @internal
*/ */
@action async refreshMetadata() { @action
async refreshMetadata() {
logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta()); logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta());
const metadata = await detectorRegistry.detectForCluster(this); const metadata = await detectorRegistry.detectForCluster(this);
const existingMetadata = this.metadata; const existingMetadata = this.metadata;
@ -432,7 +439,20 @@ export class Cluster implements ClusterModel, ClusterState {
/** /**
* @internal * @internal
*/ */
@action async refreshConnectionStatus() { private async refreshAccessibility(): Promise<void> {
this.isAdmin = await this.isClusterAdmin();
this.isGlobalWatchEnabled = await this.canUseWatchApi({ resource: "*" });
await this.refreshAllowedResources();
this.ready = true;
}
/**
* @internal
*/
@action
async refreshConnectionStatus() {
const connectionStatus = await this.getConnectionStatus(); const connectionStatus = await this.getConnectionStatus();
this.online = connectionStatus > ClusterStatus.Offline; this.online = connectionStatus > ClusterStatus.Offline;
@ -442,7 +462,8 @@ export class Cluster implements ClusterModel, ClusterState {
/** /**
* @internal * @internal
*/ */
@action async refreshAllowedResources() { @action
async refreshAllowedResources() {
this.allowedNamespaces = await this.getAllowedNamespaces(); this.allowedNamespaces = await this.getAllowedNamespaces();
this.allowedResources = await this.getAllowedResources(); this.allowedResources = await this.getAllowedResources();
} }
@ -568,6 +589,17 @@ export class Cluster implements ClusterModel, ClusterState {
}); });
} }
/**
* @internal
*/
async canUseWatchApi(customizeResource: V1ResourceAttributes = {}): Promise<boolean> {
return this.canI({
verb: "watch",
resource: "*",
...customizeResource,
});
}
toJSON(): ClusterModel { toJSON(): ClusterModel {
const model: ClusterModel = { const model: ClusterModel = {
id: this.id, id: this.id,
@ -601,6 +633,7 @@ export class Cluster implements ClusterModel, ClusterState {
isAdmin: this.isAdmin, isAdmin: this.isAdmin,
allowedNamespaces: this.allowedNamespaces, allowedNamespaces: this.allowedNamespaces,
allowedResources: this.allowedResources, allowedResources: this.allowedResources,
isGlobalWatchEnabled: this.isGlobalWatchEnabled,
}; };
return toJS(state, { return toJS(state, {
@ -672,7 +705,7 @@ export class Cluster implements ClusterModel, ClusterState {
for (const namespace of this.allowedNamespaces.slice(0, 10)) { for (const namespace of this.allowedNamespaces.slice(0, 10)) {
if (!this.resourceAccessStatuses.get(apiResource)) { if (!this.resourceAccessStatuses.get(apiResource)) {
const result = await this.canI({ const result = await this.canI({
resource: apiResource.resource, resource: apiResource.apiName,
group: apiResource.group, group: apiResource.group,
verb: "list", verb: "list",
namespace namespace
@ -687,9 +720,19 @@ export class Cluster implements ClusterModel, ClusterState {
return apiResources return apiResources
.filter((resource) => this.resourceAccessStatuses.get(resource)) .filter((resource) => this.resourceAccessStatuses.get(resource))
.map(apiResource => apiResource.resource); .map(apiResource => apiResource.apiName);
} catch (error) { } catch (error) {
return []; return [];
} }
} }
isAllowedResource(kind: string): boolean {
const apiResource = apiResources.find(resource => resource.kind === kind || resource.apiName === kind);
if (apiResource) {
return this.allowedResources.includes(apiResource.apiName);
}
return true; // allowed by default for other resources
}
} }

View File

@ -1,9 +1,12 @@
import logger from "./logger";
/** /**
* Installs Electron developer tools in the development build. * Installs Electron developer tools in the development build.
* The dependency is not bundled to the production build. * The dependency is not bundled to the production build.
*/ */
export const installDeveloperTools = async () => { export const installDeveloperTools = async () => {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
logger.info("🤓 Installing developer tools");
const { default: devToolsInstaller, REACT_DEVELOPER_TOOLS } = await import("electron-devtools-installer"); const { default: devToolsInstaller, REACT_DEVELOPER_TOOLS } = await import("electron-devtools-installer");
return devToolsInstaller([REACT_DEVELOPER_TOOLS]); return devToolsInstaller([REACT_DEVELOPER_TOOLS]);

View File

@ -10,7 +10,6 @@ import path from "path";
import { LensProxy } from "./lens-proxy"; import { LensProxy } from "./lens-proxy";
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import { ClusterManager } from "./cluster-manager"; import { ClusterManager } from "./cluster-manager";
import { AppUpdater } from "./app-updater";
import { shellSync } from "./shell-sync"; import { shellSync } from "./shell-sync";
import { getFreePort } from "./port"; import { getFreePort } from "./port";
import { mangleProxyEnv } from "./proxy-env"; import { mangleProxyEnv } from "./proxy-env";
@ -27,6 +26,9 @@ import { InstalledExtension, extensionDiscovery } from "../extensions/extension-
import type { LensExtensionId } from "../extensions/lens-extension"; import type { LensExtensionId } from "../extensions/lens-extension";
import { installDeveloperTools } from "./developer-tools"; import { installDeveloperTools } from "./developer-tools";
import { filesystemProvisionerStore } from "./extension-filesystem"; import { filesystemProvisionerStore } from "./extension-filesystem";
import { getAppVersion, getAppVersionFromProxyServer } from "../common/utils";
import { bindBroadcastHandlers } from "../common/ipc";
import { startUpdateChecking } from "./app-updater";
const workingDir = path.join(app.getPath("appData"), appName); const workingDir = path.join(app.getPath("appData"), appName);
let proxyPort: number; let proxyPort: number;
@ -62,20 +64,20 @@ if (process.env.LENS_DISABLE_GPU) {
app.on("ready", async () => { app.on("ready", async () => {
logger.info(`🚀 Starting Lens from "${workingDir}"`); logger.info(`🚀 Starting Lens from "${workingDir}"`);
logger.info("🐚 Syncing shell environment");
await shellSync(); await shellSync();
bindBroadcastHandlers();
powerMonitor.on("shutdown", () => { powerMonitor.on("shutdown", () => {
app.exit(); app.exit();
}); });
const updater = new AppUpdater();
updater.start();
registerFileProtocol("static", __static); registerFileProtocol("static", __static);
await installDeveloperTools(); await installDeveloperTools();
logger.info("💾 Loading stores");
// preload // preload
await Promise.all([ await Promise.all([
userStore.load(), userStore.load(),
@ -87,6 +89,7 @@ app.on("ready", async () => {
// find free port // find free port
try { try {
logger.info("🔑 Getting free port for LensProxy server");
proxyPort = await getFreePort(); proxyPort = await getFreePort();
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
@ -99,6 +102,7 @@ app.on("ready", async () => {
// run proxy // run proxy
try { try {
logger.info("🔌 Starting LensProxy");
// eslint-disable-next-line unused-imports/no-unused-vars-ts // eslint-disable-next-line unused-imports/no-unused-vars-ts
proxyServer = LensProxy.create(proxyPort, clusterManager); proxyServer = LensProxy.create(proxyPort, clusterManager);
} catch (error) { } catch (error) {
@ -117,9 +121,27 @@ app.on("ready", async () => {
} }
}); });
// test proxy connection
try {
logger.info("🔎 Testing LensProxy connection ...");
const versionFromProxy = await getAppVersionFromProxyServer(proxyPort);
if (getAppVersion() !== versionFromProxy) {
logger.error(`Proxy server responded with invalid response`);
}
logger.info("⚡ LensProxy connection OK");
} catch (error) {
logger.error("Checking proxy server connection failed", error);
}
extensionLoader.init(); extensionLoader.init();
extensionDiscovery.init(); extensionDiscovery.init();
logger.info("🖥️ Starting WindowManager");
windowManager = WindowManager.getInstance<WindowManager>(proxyPort); windowManager = WindowManager.getInstance<WindowManager>(proxyPort);
windowManager.whenLoaded.then(() => startUpdateChecking());
logger.info("🧩 Initializing extensions");
// call after windowManager to see splash earlier // call after windowManager to see splash earlier
try { try {

View File

@ -23,10 +23,10 @@ const kubectlMap: Map<string, string> = new Map([
["1.14", "1.14.10"], ["1.14", "1.14.10"],
["1.15", "1.15.11"], ["1.15", "1.15.11"],
["1.16", "1.16.15"], ["1.16", "1.16.15"],
["1.17", bundledVersion], ["1.17", "1.17.17"],
["1.18", "1.18.14"], ["1.18", bundledVersion],
["1.19", "1.19.5"], ["1.19", "1.19.7"],
["1.20", "1.20.0"] ["1.20", "1.20.2"]
]); ]);
const packageMirrors: Map<string, string> = new Map([ const packageMirrors: Map<string, string> = new Map([
["default", "https://storage.googleapis.com/kubernetes-release/release"], ["default", "https://storage.googleapis.com/kubernetes-release/release"],

View File

@ -30,7 +30,7 @@ export class LensProxy {
listen(port = this.port): this { listen(port = this.port): this {
this.proxyServer = this.buildCustomProxy().listen(port); this.proxyServer = this.buildCustomProxy().listen(port);
logger.info(`LensProxy server has started at ${this.origin}`); logger.info(`[LENS-PROXY]: Proxy server has started at ${this.origin}`);
return this; return this;
} }
@ -43,13 +43,13 @@ export class LensProxy {
protected buildCustomProxy(): http.Server { protected buildCustomProxy(): http.Server {
const proxy = this.createProxy(); const proxy = this.createProxy();
const proxyCert = getProxyCertificate() const proxyCert = getProxyCertificate();
const spdyProxy = spdy.createServer({ const spdyProxy = spdy.createServer({
key: proxyCert.private, key: proxyCert.private,
cert: proxyCert.cert, cert: proxyCert.cert,
spdy: { spdy: {
plain: false, plain: false,
protocols: ["http/1.1", "spdy/3.1"] protocols: ["h2", "http/1.1", "spdy/3.1"]
} }
}, (req: http.IncomingMessage, res: http.ServerResponse) => { }, (req: http.IncomingMessage, res: http.ServerResponse) => {
this.handleRequest(proxy, req, res); this.handleRequest(proxy, req, res);
@ -198,7 +198,8 @@ export class LensProxy {
if (proxyTarget) { if (proxyTarget) {
// allow to fetch apis in "clusterId.localhost:port" from "localhost:port" // allow to fetch apis in "clusterId.localhost:port" from "localhost:port"
res.setHeader("Access-Control-Allow-Origin", this.origin); // this should be safe because we have already validated cluster uuid
res.setHeader("Access-Control-Allow-Origin", "*");
return proxy.web(req, res, proxyTarget); return proxy.web(req, res, proxyTarget);
} }

View File

@ -10,6 +10,7 @@ import { extensionsURL } from "../renderer/components/+extensions/extensions.rou
import { menuRegistry } from "../extensions/registries/menu-registry"; import { menuRegistry } from "../extensions/registries/menu-registry";
import logger from "./logger"; import logger from "./logger";
import { exitApp } from "./exit-app"; import { exitApp } from "./exit-app";
import { broadcastMessage } from "../common/ipc";
export type MenuTopId = "mac" | "file" | "edit" | "view" | "help"; export type MenuTopId = "mac" | "file" | "edit" | "view" | "help";
@ -173,6 +174,14 @@ export function buildMenu(windowManager: WindowManager) {
const viewMenu: MenuItemConstructorOptions = { const viewMenu: MenuItemConstructorOptions = {
label: "View", label: "View",
submenu: [ submenu: [
{
label: "Command Palette...",
accelerator: "Shift+CmdOrCtrl+P",
click() {
broadcastMessage("command-palette:open");
}
},
{ type: "separator" },
{ {
label: "Back", label: "Back",
accelerator: "CmdOrCtrl+[", accelerator: "CmdOrCtrl+[",

View File

@ -5,7 +5,7 @@ import path from "path";
import { readFile } from "fs-extra"; import { readFile } from "fs-extra";
import { Cluster } from "./cluster"; import { Cluster } from "./cluster";
import { apiPrefix, appName, publicPath, isDevelopment, webpackDevServerPort } from "../common/vars"; import { apiPrefix, appName, publicPath, isDevelopment, webpackDevServerPort } from "../common/vars";
import { helmRoute, kubeconfigRoute, metricsRoute, portForwardRoute, resourceApplierRoute, watchRoute } from "./routes"; import { helmRoute, kubeconfigRoute, metricsRoute, portForwardRoute, resourceApplierRoute, versionRoute } from "./routes";
import logger from "./logger"; import logger from "./logger";
export interface RouterRequestOpts { export interface RouterRequestOpts {
@ -143,11 +143,9 @@ export class Router {
this.handleStaticFile(params.path, response, req); this.handleStaticFile(params.path, response, req);
}); });
this.router.add({ method: "get", path: "/version"}, versionRoute.getVersion.bind(versionRoute));
this.router.add({ method: "get", path: `${apiPrefix}/kubeconfig/service-account/{namespace}/{account}` }, kubeconfigRoute.routeServiceAccountRoute.bind(kubeconfigRoute)); this.router.add({ method: "get", path: `${apiPrefix}/kubeconfig/service-account/{namespace}/{account}` }, kubeconfigRoute.routeServiceAccountRoute.bind(kubeconfigRoute));
// Watch API
this.router.add({ method: "get", path: `${apiPrefix}/watch` }, watchRoute.routeWatch.bind(watchRoute));
// Metrics API // Metrics API
this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute)); this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute));

View File

@ -1,6 +1,6 @@
export * from "./kubeconfig-route"; export * from "./kubeconfig-route";
export * from "./metrics-route"; export * from "./metrics-route";
export * from "./port-forward-route"; export * from "./port-forward-route";
export * from "./watch-route";
export * from "./helm-route"; export * from "./helm-route";
export * from "./resource-applier-route"; export * from "./resource-applier-route";
export * from "./version-route";

View File

@ -0,0 +1,13 @@
import { LensApiRequest } from "../router";
import { LensApi } from "../lens-api";
import { getAppVersion } from "../../common/utils";
class VersionRoute extends LensApi {
public async getVersion(request: LensApiRequest) {
const { response } = request;
this.respondJson(response, { version: getAppVersion()}, 200);
}
}
export const versionRoute = new VersionRoute();

View File

@ -1,115 +0,0 @@
import { LensApiRequest } from "../router";
import { LensApi } from "../lens-api";
import { Watch, KubeConfig } from "@kubernetes/client-node";
import { ServerResponse } from "http";
import { Request } from "request";
import logger from "../logger";
class ApiWatcher {
private apiUrl: string;
private response: ServerResponse;
private watchRequest: Request;
private watch: Watch;
private processor: NodeJS.Timeout;
private eventBuffer: any[] = [];
constructor(apiUrl: string, kubeConfig: KubeConfig, response: ServerResponse) {
this.apiUrl = apiUrl;
this.watch = new Watch(kubeConfig);
this.response = response;
}
public async start() {
if (this.processor) {
clearInterval(this.processor);
}
this.processor = setInterval(() => {
const events = this.eventBuffer.splice(0);
events.map(event => this.sendEvent(event));
this.response.flushHeaders();
}, 50);
this.watchRequest = await this.watch.watch(this.apiUrl, {}, this.watchHandler.bind(this), this.doneHandler.bind(this));
}
public stop() {
if (!this.watchRequest) { return; }
if (this.processor) {
clearInterval(this.processor);
}
logger.debug(`Stopping watcher for api: ${this.apiUrl}`);
try {
this.watchRequest.abort();
this.sendEvent({
type: "STREAM_END",
url: this.apiUrl,
status: 410,
});
logger.debug("watch aborted");
} catch (error) {
logger.error(`Watch abort errored:${error}`);
}
}
private watchHandler(phase: string, obj: any) {
this.eventBuffer.push({
type: phase,
object: obj
});
}
private doneHandler(error: Error) {
if (error) logger.warn(`watch ended: ${error.toString()}`);
this.watchRequest.abort();
}
private sendEvent(evt: any) {
// convert to "text/event-stream" format
this.response.write(`data: ${JSON.stringify(evt)}\n\n`);
}
}
class WatchRoute extends LensApi {
public async routeWatch(request: LensApiRequest) {
const { response, cluster} = request;
const apis: string[] = request.query.getAll("api");
const watchers: ApiWatcher[] = [];
if (!apis.length) {
this.respondJson(response, {
message: "Empty request. Query params 'api' are not provided.",
example: "?api=/api/v1/pods&api=/api/v1/nodes",
}, 400);
return;
}
response.setHeader("Content-Type", "text/event-stream");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Connection", "keep-alive");
logger.debug(`watch using kubeconfig:${JSON.stringify(cluster.getProxyKubeconfig(), null, 2)}`);
apis.forEach(apiUrl => {
const watcher = new ApiWatcher(apiUrl, cluster.getProxyKubeconfig(), response);
watcher.start();
watchers.push(watcher);
});
request.raw.req.on("close", () => {
logger.debug("Watch request closed");
watchers.map(watcher => watcher.stop());
});
request.raw.req.on("end", () => {
logger.debug("Watch request ended");
watchers.map(watcher => watcher.stop());
});
}
}
export const watchRoute = new WatchRoute();

View File

@ -1,9 +1,9 @@
import path from "path"; import path from "path";
import packageInfo from "../../package.json"; import packageInfo from "../../package.json";
import { dialog, Menu, NativeImage, Tray } from "electron"; import { Menu, NativeImage, Tray } from "electron";
import { autorun } from "mobx"; import { autorun } from "mobx";
import { showAbout } from "./menu"; import { showAbout } from "./menu";
import { AppUpdater } from "./app-updater"; import { checkForUpdates } from "./app-updater";
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import { clusterStore } from "../common/cluster-store"; import { clusterStore } from "../common/cluster-store";
import { workspaceStore } from "../common/workspace-store"; import { workspaceStore } from "../common/workspace-store";
@ -62,16 +62,6 @@ function buildTray(icon: string | NativeImage, menu: Menu, windowManager: Window
function createTrayMenu(windowManager: WindowManager): Menu { function createTrayMenu(windowManager: WindowManager): Menu {
return Menu.buildFromTemplate([ return Menu.buildFromTemplate([
{
label: "About Lens",
async click() {
// note: argument[1] (browserWindow) not available when app is not focused / hidden
const browserWindow = await windowManager.ensureMainWindow();
showAbout(browserWindow);
},
},
{ type: "separator" },
{ {
label: "Open Lens", label: "Open Lens",
async click() { async click() {
@ -112,16 +102,17 @@ function createTrayMenu(windowManager: WindowManager): Menu {
{ {
label: "Check for updates", label: "Check for updates",
async click() { async click() {
const result = await AppUpdater.checkForUpdates(); await checkForUpdates();
await windowManager.ensureMainWindow();
},
},
{
label: "About Lens",
async click() {
// note: argument[1] (browserWindow) not available when app is not focused / hidden
const browserWindow = await windowManager.ensureMainWindow();
if (!result) { showAbout(browserWindow);
const browserWindow = await windowManager.ensureMainWindow();
dialog.showMessageBoxSync(browserWindow, {
message: "No updates available",
type: "info",
});
}
}, },
}, },
{ type: "separator" }, { type: "separator" },

View File

@ -1,5 +1,5 @@
import type { ClusterId } from "../common/cluster-store"; import type { ClusterId } from "../common/cluster-store";
import { observable } from "mobx"; import { observable, when } from "mobx";
import { app, BrowserWindow, dialog, shell, webContents } from "electron"; import { app, BrowserWindow, dialog, shell, webContents } from "electron";
import windowStateKeeper from "electron-window-state"; import windowStateKeeper from "electron-window-state";
import { appEventBus } from "../common/event-bus"; import { appEventBus } from "../common/event-bus";
@ -8,6 +8,7 @@ import { initMenu } from "./menu";
import { initTray } from "./tray"; import { initTray } from "./tray";
import { Singleton } from "../common/utils"; import { Singleton } from "../common/utils";
import { ClusterFrameInfo, clusterFrameMap } from "../common/cluster-frames"; import { ClusterFrameInfo, clusterFrameMap } from "../common/cluster-frames";
import logger from "./logger";
export class WindowManager extends Singleton { export class WindowManager extends Singleton {
protected mainWindow: BrowserWindow; protected mainWindow: BrowserWindow;
@ -15,6 +16,9 @@ export class WindowManager extends Singleton {
protected windowState: windowStateKeeper.State; protected windowState: windowStateKeeper.State;
protected disposers: Record<string, Function> = {}; protected disposers: Record<string, Function> = {};
@observable mainViewInitiallyLoaded = false;
whenLoaded = when(() => this.mainViewInitiallyLoaded);
@observable activeClusterId: ClusterId; @observable activeClusterId: ClusterId;
constructor(protected proxyPort: number) { constructor(protected proxyPort: number) {
@ -81,16 +85,26 @@ export class WindowManager extends Singleton {
this.splashWindow = null; this.splashWindow = null;
app.dock?.hide(); // hide icon in dock (mac-os) app.dock?.hide(); // hide icon in dock (mac-os)
}); });
this.mainWindow.webContents.on("did-fail-load", (_event, code, desc) => {
logger.error(`[WINDOW-MANAGER]: Failed to load Main window`, { code, desc });
});
this.mainWindow.webContents.on("did-finish-load", () => {
logger.info("[WINDOW-MANAGER]: Main window loaded");
});
} }
try { try {
if (showSplash) await this.showSplash(); if (showSplash) await this.showSplash();
logger.info(`[WINDOW-MANAGER]: Loading Main window from url: ${this.mainUrl} ...`);
await this.mainWindow.loadURL(this.mainUrl); await this.mainWindow.loadURL(this.mainUrl);
this.mainWindow.show(); this.mainWindow.show();
this.splashWindow?.close(); this.splashWindow?.close();
setTimeout(() => { setTimeout(() => {
appEventBus.emit({ name: "app", action: "start" }); appEventBus.emit({ name: "app", action: "start" });
}, 1000); }, 1000);
this.mainViewInitiallyLoaded = true;
} catch (err) { } catch (err) {
dialog.showErrorBox("ERROR!", err.toString()); dialog.showErrorBox("ERROR!", err.toString());
} }

View File

@ -2,16 +2,16 @@ import type { KubeObjectStore } from "../kube-object.store";
import { action, observable } from "mobx"; import { action, observable } from "mobx";
import { autobind } from "../utils"; import { autobind } from "../utils";
import { KubeApi } from "./kube-api"; import { KubeApi, parseKubeApi } from "./kube-api";
@autobind() @autobind()
export class ApiManager { export class ApiManager {
private apis = observable.map<string, KubeApi>(); private apis = observable.map<string, KubeApi>();
private stores = observable.map<KubeApi, KubeObjectStore>(); private stores = observable.map<string, KubeObjectStore>();
getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) { getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) {
if (typeof pathOrCallback === "string") { if (typeof pathOrCallback === "string") {
return this.apis.get(pathOrCallback) || this.apis.get(KubeApi.parseApi(pathOrCallback).apiBase); return this.apis.get(pathOrCallback) || this.apis.get(parseKubeApi(pathOrCallback).apiBase);
} }
return Array.from(this.apis.values()).find(pathOrCallback ?? (() => true)); return Array.from(this.apis.values()).find(pathOrCallback ?? (() => true));
@ -46,12 +46,12 @@ export class ApiManager {
@action @action
registerStore(store: KubeObjectStore, apis: KubeApi[] = [store.api]) { registerStore(store: KubeObjectStore, apis: KubeApi[] = [store.api]) {
apis.forEach(api => { apis.forEach(api => {
this.stores.set(api, store); this.stores.set(api.apiBase, store);
}); });
} }
getStore<S extends KubeObjectStore>(api: string | KubeApi): S { getStore<S extends KubeObjectStore>(api: string | KubeApi): S {
return this.stores.get(this.resolveApi(api)) as S; return this.stores.get(this.resolveApi(api)?.apiBase) as S;
} }
} }

View File

@ -86,7 +86,7 @@ export class HelmChart {
tillerVersion?: string; tillerVersion?: string;
getId() { getId() {
return this.digest; return `${this.apiVersion}/${this.name}@${this.getAppVersion()}`;
} }
getName() { getName() {

View File

@ -57,6 +57,7 @@ export interface IReleaseRevision {
updated: string; updated: string;
status: string; status: string;
chart: string; chart: string;
app_version: string;
description: string; description: string;
} }

View File

@ -104,6 +104,9 @@ export interface IPodContainer {
configMapRef?: { configMapRef?: {
name: string; name: string;
}; };
secretRef?: {
name: string;
}
}[]; }[];
volumeMounts?: { volumeMounts?: {
name: string; name: string;

View File

@ -3,7 +3,6 @@
import { stringify } from "querystring"; import { stringify } from "querystring";
import { EventEmitter } from "../../common/event-emitter"; import { EventEmitter } from "../../common/event-emitter";
import { cancelableFetch } from "../utils/cancelableFetch"; import { cancelableFetch } from "../utils/cancelableFetch";
export interface JsonApiData { export interface JsonApiData {
} }
@ -55,6 +54,34 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
return this.request<T>(path, params, { ...reqInit, method: "get" }); return this.request<T>(path, params, { ...reqInit, method: "get" });
} }
getResponse(path: string, params?: P, init: RequestInit = {}): Promise<Response> {
const reqPath = `${this.config.apiBase}${path}`;
//const subdomain = randomBytes(2).toString("hex");
let reqUrl = reqPath; //`http://${subdomain}.${window.location.host}${reqPath}`; // hack around browser connection limits (chromium allows 6 per domain)
const reqInit: RequestInit = { ...init };
const { query } = params || {} as P;
if (!reqInit.method) {
reqInit.method = "get";
}
if (query) {
const queryString = stringify(query);
reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString;
}
const infoLog: JsonApiLog = {
method: reqInit.method.toUpperCase(),
reqUrl: reqPath,
reqInit,
};
this.writeLog({ ...infoLog });
return fetch(reqUrl, reqInit);
}
post<T = D>(path: string, params?: P, reqInit: RequestInit = {}) { post<T = D>(path: string, params?: P, reqInit: RequestInit = {}) {
return this.request<T>(path, params, { ...reqInit, method: "post" }); return this.request<T>(path, params, { ...reqInit, method: "post" });
} }

View File

@ -9,7 +9,9 @@ import { apiKube } from "./index";
import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; import { createKubeApiURL, parseKubeApi } from "./kube-api-parse";
import { KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api"; import { KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api";
import { IKubeObjectConstructor, KubeObject } from "./kube-object"; import { IKubeObjectConstructor, KubeObject } from "./kube-object";
import { kubeWatchApi } from "./kube-watch-api"; import byline from "byline";
import { ReadableWebToNodeStream } from "readable-web-to-node-stream";
import { IKubeWatchEvent } from "./kube-watch-api";
export interface IKubeApiOptions<T extends KubeObject> { export interface IKubeApiOptions<T extends KubeObject> {
/** /**
@ -91,15 +93,13 @@ export function ensureObjectSelfLink(api: KubeApi, object: KubeJsonApiData) {
} }
} }
type KubeApiWatchOptions = {
namespace: string;
callback?: (data: IKubeWatchEvent) => void;
abortController?: AbortController
};
export class KubeApi<T extends KubeObject = any> { export class KubeApi<T extends KubeObject = any> {
static parseApi = parseKubeApi;
static watchAll(...apis: KubeApi[]) {
const disposers = apis.map(api => api.watch());
return () => disposers.forEach(unwatch => unwatch());
}
readonly kind: string; readonly kind: string;
readonly apiBase: string; readonly apiBase: string;
readonly apiPrefix: string; readonly apiPrefix: string;
@ -112,6 +112,7 @@ export class KubeApi<T extends KubeObject = any> {
public objectConstructor: IKubeObjectConstructor<T>; public objectConstructor: IKubeObjectConstructor<T>;
protected request: KubeJsonApi; protected request: KubeJsonApi;
protected resourceVersions = new Map<string, string>(); protected resourceVersions = new Map<string, string>();
protected watchDisposer: () => void;
constructor(protected options: IKubeApiOptions<T>) { constructor(protected options: IKubeApiOptions<T>) {
const { const {
@ -124,7 +125,7 @@ export class KubeApi<T extends KubeObject = any> {
if (!options.apiBase) { if (!options.apiBase) {
options.apiBase = objectConstructor.apiBase; options.apiBase = objectConstructor.apiBase;
} }
const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = KubeApi.parseApi(options.apiBase); const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = parseKubeApi(options.apiBase);
this.kind = kind; this.kind = kind;
this.isNamespaced = isNamespaced; this.isNamespaced = isNamespaced;
@ -157,7 +158,7 @@ export class KubeApi<T extends KubeObject = any> {
for (const apiUrl of apiBases) { for (const apiUrl of apiBases) {
// Split e.g. "/apis/extensions/v1beta1/ingresses" to parts // Split e.g. "/apis/extensions/v1beta1/ingresses" to parts
const { apiPrefix, apiGroup, apiVersionWithGroup, resource } = KubeApi.parseApi(apiUrl); const { apiPrefix, apiGroup, apiVersionWithGroup, resource } = parseKubeApi(apiUrl);
// Request available resources // Request available resources
try { try {
@ -365,8 +366,88 @@ export class KubeApi<T extends KubeObject = any> {
}); });
} }
watch(): () => void { watch(opts: KubeApiWatchOptions = { namespace: "" }): () => void {
return kubeWatchApi.subscribe(this); if (!opts.abortController) {
opts.abortController = new AbortController();
}
const { abortController, namespace, callback } = opts;
const watchUrl = this.getWatchUrl(namespace);
const responsePromise = this.request.getResponse(watchUrl, null, {
signal: abortController.signal
});
responsePromise.then((response) => {
if (!response.ok && !abortController.signal.aborted) {
if (response.status === 410) { // resourceVersion has gone
setTimeout(() => {
this.refreshResourceVersion().then(() => {
this.watch({...opts, abortController});
});
}, 1000);
} else if (response.status >= 500) { // k8s is having hard time
setTimeout(() => {
this.watch({...opts, abortController});
}, 5000);
}
return;
}
const nodeStream = new ReadableWebToNodeStream(response.body);
const stream = byline(nodeStream);
stream.on("data", (line) => {
try {
const event: IKubeWatchEvent = JSON.parse(line);
this.modifyWatchEvent(event);
if (callback) {
callback(event);
}
} catch (ignore) {
// ignore parse errors
}
});
stream.on("close", () => {
setTimeout(() => {
if (!abortController.signal.aborted) this.watch({...opts, namespace, callback});
}, 1000);
});
}, (error) => {
if (error instanceof DOMException) return; // AbortController rejects, we can ignore it
console.error("watch rejected", error);
}).catch((error) => {
console.error("watch error", error);
});
const disposer = () => {
abortController.abort();
};
return disposer;
}
protected modifyWatchEvent(event: IKubeWatchEvent) {
switch (event.type) {
case "ADDED":
case "DELETED":
case "MODIFIED": {
ensureObjectSelfLink(this, event.object);
const { namespace, resourceVersion } = event.object.metadata;
this.setResourceVersion(namespace, resourceVersion);
this.setResourceVersion("", resourceVersion);
break;
}
}
} }
} }

View File

@ -21,7 +21,7 @@ export interface KubeJsonApiData extends JsonApiData {
resourceVersion: string; resourceVersion: string;
continue?: string; continue?: string;
finalizers?: string[]; finalizers?: string[];
selfLink: string; selfLink?: string;
labels?: { labels?: {
[label: string]: string; [label: string]: string;
}; };

View File

@ -1,183 +1,141 @@
// Kubernetes watch-api consumer // Kubernetes watch-api client
// API: https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams
import { computed, observable, reaction } from "mobx";
import { stringify } from "querystring";
import { autobind, EventEmitter } from "../utils";
import { KubeJsonApiData } from "./kube-json-api";
import type { KubeObjectStore } from "../kube-object.store"; import type { KubeObjectStore } from "../kube-object.store";
import { ensureObjectSelfLink, KubeApi } from "./kube-api"; import type { ClusterContext } from "../components/context";
import { apiManager } from "./api-manager";
import { apiPrefix, isDevelopment } from "../../common/vars";
import { getHostedCluster } from "../../common/cluster-store";
export interface IKubeWatchEvent<T = any> { import plimit from "p-limit";
import { comparer, IReactionDisposer, observable, reaction, when } from "mobx";
import { autobind, noop } from "../utils";
import { KubeApi } from "./kube-api";
import { KubeJsonApiData } from "./kube-json-api";
import { isDebugging, isProduction } from "../../common/vars";
export interface IKubeWatchEvent<T = KubeJsonApiData> {
type: "ADDED" | "MODIFIED" | "DELETED"; type: "ADDED" | "MODIFIED" | "DELETED";
object?: T; object?: T;
} }
export interface IKubeWatchRouteEvent { export interface IKubeWatchSubscribeStoreOptions {
type: "STREAM_END"; namespaces?: string[]; // default: all accessible namespaces
url: string; preload?: boolean; // preload store items, default: true
status: number; waitUntilLoaded?: boolean; // subscribe only after loading all stores, default: true
loadOnce?: boolean; // check store.isLoaded to skip loading if done already, default: false
} }
export interface IKubeWatchRouteQuery { export interface IKubeWatchLog {
api: string | string[]; message: string | string[] | Error;
meta?: object;
cssStyle?: string;
} }
@autobind() @autobind()
export class KubeWatchApi { export class KubeWatchApi {
protected evtSource: EventSource; @observable context: ClusterContext = null;
protected onData = new EventEmitter<[IKubeWatchEvent]>(); @observable subscribers = observable.map<KubeApi, number>();
protected subscribers = observable.map<KubeApi, number>(); @observable isConnected = false;
protected reconnectTimeoutMs = 5000;
protected maxReconnectsOnError = 10; contextReady = when(() => Boolean(this.context));
protected reconnectAttempts = this.maxReconnectsOnError;
constructor() { constructor() {
reaction(() => this.activeApis, () => this.connect(), { this.init();
fireImmediately: true,
delay: 500,
});
} }
@computed get activeApis() { private async init() {
return Array.from(this.subscribers.keys()); await this.contextReady;
} }
getSubscribersCount(api: KubeApi) { isAllowedApi(api: KubeApi): boolean {
return this.subscribers.get(api) || 0; return Boolean(this.context?.cluster.isAllowedResource(api.kind));
} }
subscribe(...apis: KubeApi[]) { preloadStores(stores: KubeObjectStore[], opts: { namespaces?: string[], loadOnce?: boolean } = {}) {
apis.forEach(api => { const limitRequests = plimit(1); // load stores one by one to allow quick skipping when fast clicking btw pages
this.subscribers.set(api, this.getSubscribersCount(api) + 1); const preloading: Promise<any>[] = [];
});
return () => apis.forEach(api => { for (const store of stores) {
const count = this.getSubscribersCount(api) - 1; preloading.push(limitRequests(async () => {
if (store.isLoaded && opts.loadOnce) return; // skip
if (count <= 0) this.subscribers.delete(api); return store.loadAll({ namespaces: opts.namespaces });
else this.subscribers.set(api, count); }));
}); }
}
protected getQuery(): Partial<IKubeWatchRouteQuery> {
const { isAdmin, allowedNamespaces } = getHostedCluster();
return { return {
api: this.activeApis.map(api => { loading: Promise.allSettled(preloading),
if (isAdmin) return api.getWatchUrl(); cancelLoading: () => limitRequests.clearQueue(),
return allowedNamespaces.map(namespace => api.getWatchUrl(namespace));
}).flat()
}; };
} }
// todo: maybe switch to websocket to avoid often reconnects subscribeStores(stores: KubeObjectStore[], opts: IKubeWatchSubscribeStoreOptions = {}): () => void {
@autobind() const { preload = true, waitUntilLoaded = true, loadOnce = false, } = opts;
protected connect() { const subscribingNamespaces = opts.namespaces ?? this.context?.allNamespaces ?? [];
if (this.evtSource) this.disconnect(); // close previous connection const unsubscribeList: Function[] = [];
let isUnsubscribed = false;
if (!this.activeApis.length) { const load = (namespaces = subscribingNamespaces) => this.preloadStores(stores, { namespaces, loadOnce });
let preloading = preload && load();
let cancelReloading: IReactionDisposer = noop;
const subscribe = () => {
if (isUnsubscribed) return;
stores.forEach((store) => {
unsubscribeList.push(store.subscribe());
});
};
if (preloading) {
if (waitUntilLoaded) {
preloading.loading.then(subscribe, error => {
this.log({
message: new Error("Loading stores has failed"),
meta: { stores, error, options: opts },
});
});
} else {
subscribe();
}
// reload stores only for context namespaces change
cancelReloading = reaction(() => this.context?.contextNamespaces, namespaces => {
preloading?.cancelLoading();
unsubscribeList.forEach(unsubscribe => unsubscribe());
unsubscribeList.length = 0;
preloading = load(namespaces);
preloading.loading.then(subscribe);
}, {
equals: comparer.shallow,
});
}
// unsubscribe
return () => {
if (isUnsubscribed) return;
isUnsubscribed = true;
cancelReloading();
preloading?.cancelLoading();
unsubscribeList.forEach(unsubscribe => unsubscribe());
unsubscribeList.length = 0;
};
}
protected log({ message, cssStyle = "", meta = {} }: IKubeWatchLog) {
if (isProduction && !isDebugging) {
return; return;
} }
const query = this.getQuery();
const apiUrl = `${apiPrefix}/watch?${stringify(query)}`;
this.evtSource = new EventSource(apiUrl); const logInfo = [`%c[KUBE-WATCH-API]:`, `font-weight: bold; ${cssStyle}`, message].flat().map(String);
this.evtSource.onmessage = this.onMessage; const logMeta = {
this.evtSource.onerror = this.onError; time: new Date().toLocaleString(),
this.writeLog("CONNECTING", query.api); ...meta,
}
reconnect() {
if (!this.evtSource || this.evtSource.readyState !== EventSource.OPEN) {
this.reconnectAttempts = this.maxReconnectsOnError;
this.connect();
}
}
protected disconnect() {
if (!this.evtSource) return;
this.evtSource.close();
this.evtSource.onmessage = null;
this.evtSource = null;
}
protected onMessage(evt: MessageEvent) {
if (!evt.data) return;
const data = JSON.parse(evt.data);
if ((data as IKubeWatchEvent).object) {
this.onData.emit(data);
} else {
this.onRouteEvent(data);
}
}
protected async onRouteEvent(event: IKubeWatchRouteEvent) {
if (event.type === "STREAM_END") {
this.disconnect();
const { apiBase, namespace } = KubeApi.parseApi(event.url);
const api = apiManager.getApi(apiBase);
if (api) {
try {
await api.refreshResourceVersion({ namespace });
this.reconnect();
} catch (error) {
console.error("failed to refresh resource version", error);
if (this.subscribers.size > 0) {
setTimeout(() => {
this.onRouteEvent(event);
}, 1000);
}
}
}
}
}
protected onError(evt: MessageEvent) {
const { reconnectAttempts: attemptsRemain, reconnectTimeoutMs } = this;
if (evt.eventPhase === EventSource.CLOSED) {
if (attemptsRemain > 0) {
this.reconnectAttempts--;
setTimeout(() => this.connect(), reconnectTimeoutMs);
}
}
}
protected writeLog(...data: any[]) {
if (isDevelopment) {
console.log("%cKUBE-WATCH-API:", `font-weight: bold`, ...data);
}
}
addListener(store: KubeObjectStore, callback: (evt: IKubeWatchEvent) => void) {
const listener = (evt: IKubeWatchEvent<KubeJsonApiData>) => {
const { namespace, resourceVersion } = evt.object.metadata;
const api = apiManager.getApiByKind(evt.object.kind, evt.object.apiVersion);
api.setResourceVersion(namespace, resourceVersion);
api.setResourceVersion("", resourceVersion);
ensureObjectSelfLink(api, evt.object);
if (store == apiManager.getStore(api)) {
callback(evt);
}
}; };
this.onData.addListener(listener); if (message instanceof Error) {
console.error(...logInfo, logMeta);
return () => this.onData.removeListener(listener); } else {
} console.info(...logInfo, logMeta);
}
reset() {
this.subscribers.clear();
} }
} }

View File

@ -1,7 +1,7 @@
import get from "lodash/get"; import get from "lodash/get";
import { KubeObject } from "./kube-object"; import { KubeObject } from "./kube-object";
interface IToleration { export interface IToleration {
key?: string; key?: string;
operator?: string; operator?: string;
effect?: string; effect?: string;

View File

@ -11,8 +11,11 @@ import { navigation } from "../../navigation";
import { ItemListLayout } from "../item-object-list/item-list-layout"; import { ItemListLayout } from "../item-object-list/item-list-layout";
import { SearchInputUrl } from "../input"; import { SearchInputUrl } from "../input";
enum sortBy { enum columnId {
name = "name", name = "name",
description = "description",
version = "version",
appVersion = "app-version",
repo = "repo", repo = "repo",
} }
@ -53,13 +56,15 @@ export class HelmCharts extends Component<Props> {
return ( return (
<> <>
<ItemListLayout <ItemListLayout
isConfigurable
tableId="helm_charts"
className="HelmCharts" className="HelmCharts"
store={helmChartStore} store={helmChartStore}
isClusterScoped={true} isClusterScoped={true}
isSelectable={false} isSelectable={false}
sortingCallbacks={{ sortingCallbacks={{
[sortBy.name]: (chart: HelmChart) => chart.getName(), [columnId.name]: (chart: HelmChart) => chart.getName(),
[sortBy.repo]: (chart: HelmChart) => chart.getRepository(), [columnId.repo]: (chart: HelmChart) => chart.getRepository(),
}} }}
searchFilters={[ searchFilters={[
(chart: HelmChart) => chart.getName(), (chart: HelmChart) => chart.getName(),
@ -74,13 +79,12 @@ export class HelmCharts extends Component<Props> {
<SearchInputUrl placeholder={`Search Helm Charts`} /> <SearchInputUrl placeholder={`Search Helm Charts`} />
)} )}
renderTableHeader={[ renderTableHeader={[
{ className: "icon" }, { className: "icon", showWithColumn: columnId.name },
{ title: "Name", className: "name", sortBy: sortBy.name }, { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ title: "Description", className: "description" }, { title: "Description", className: "description", id: columnId.description },
{ title: "Version", className: "version" }, { title: "Version", className: "version", id: columnId.version },
{ title: "App Version", className: "app-version" }, { title: "App Version", className: "app-version", id: columnId.appVersion },
{ title: "Repository", className: "repository", sortBy: sortBy.repo }, { title: "Repository", className: "repository", sortBy: columnId.repo, id: columnId.repo },
]} ]}
renderTableContents={(chart: HelmChart) => [ renderTableContents={(chart: HelmChart) => [
<figure key="image"> <figure key="image">
@ -93,7 +97,8 @@ export class HelmCharts extends Component<Props> {
chart.getDescription(), chart.getDescription(),
chart.getVersion(), chart.getVersion(),
chart.getAppVersion(), chart.getAppVersion(),
{ title: chart.getRepository(), className: chart.getRepository().toLowerCase() } { title: chart.getRepository(), className: chart.getRepository().toLowerCase() },
{ className: "menu" }
]} ]}
detailsItem={this.selectedChart} detailsItem={this.selectedChart}
onDetails={this.showDetails} onDetails={this.showDetails}

View File

@ -77,7 +77,8 @@ export class ReleaseRollbackDialog extends React.Component<Props> {
themeName="light" themeName="light"
value={revision} value={revision}
options={revisions} options={revisions}
formatOptionLabel={({ value }: SelectOption<IReleaseRevision>) => `${value.revision} - ${value.chart}`} formatOptionLabel={({ value }: SelectOption<IReleaseRevision>) => `${value.revision} - ${value.chart}
- ${value.app_version}, updated: ${new Date(value.updated).toLocaleString()}`}
onChange={({ value }: SelectOption<IReleaseRevision>) => this.revision = value} onChange={({ value }: SelectOption<IReleaseRevision>) => this.revision = value}
/> />
</div> </div>

View File

@ -5,7 +5,7 @@ import { HelmRelease, helmReleasesApi, IReleaseCreatePayload, IReleaseUpdatePayl
import { ItemStore } from "../../item.store"; import { ItemStore } from "../../item.store";
import { Secret } from "../../api/endpoints"; import { Secret } from "../../api/endpoints";
import { secretsStore } from "../+config-secrets/secrets.store"; import { secretsStore } from "../+config-secrets/secrets.store";
import { getHostedCluster } from "../../../common/cluster-store"; import { namespaceStore } from "../+namespaces/namespace.store";
@autobind() @autobind()
export class ReleaseStore extends ItemStore<HelmRelease> { export class ReleaseStore extends ItemStore<HelmRelease> {
@ -58,38 +58,35 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
} }
@action @action
async loadAll() { async loadAll(namespaces = namespaceStore.allowedNamespaces) {
this.isLoading = true; this.isLoading = true;
let items;
try { try {
const { isAdmin, allowedNamespaces } = getHostedCluster(); const items = await this.loadItems(namespaces);
items = await this.loadItems(!isAdmin ? allowedNamespaces : null); this.items.replace(this.sortItems(items));
} finally {
if (items) {
items = this.sortItems(items);
this.items.replace(items);
}
this.isLoaded = true; this.isLoaded = true;
} catch (error) {
console.error(`Loading Helm Chart releases has failed: ${error}`);
} finally {
this.isLoading = false; this.isLoading = false;
} }
} }
async loadItems(namespaces?: string[]) { async loadFromContextNamespaces(): Promise<void> {
if (!namespaces) { return this.loadAll(namespaceStore.contextNamespaces);
return helmReleasesApi.list(); }
} else {
return Promise async loadItems(namespaces: string[]) {
.all(namespaces.map(namespace => helmReleasesApi.list(namespace))) return Promise
.then(items => items.flat()); .all(namespaces.map(namespace => helmReleasesApi.list(namespace)))
} .then(items => items.flat());
} }
async create(payload: IReleaseCreatePayload) { async create(payload: IReleaseCreatePayload) {
const response = await helmReleasesApi.create(payload); const response = await helmReleasesApi.create(payload);
if (this.isLoaded) this.loadAll(); if (this.isLoaded) this.loadFromContextNamespaces();
return response; return response;
} }
@ -97,7 +94,7 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
async update(name: string, namespace: string, payload: IReleaseUpdatePayload) { async update(name: string, namespace: string, payload: IReleaseUpdatePayload) {
const response = await helmReleasesApi.update(name, namespace, payload); const response = await helmReleasesApi.update(name, namespace, payload);
if (this.isLoaded) this.loadAll(); if (this.isLoaded) this.loadFromContextNamespaces();
return response; return response;
} }
@ -105,7 +102,7 @@ export class ReleaseStore extends ItemStore<HelmRelease> {
async rollback(name: string, namespace: string, revision: number) { async rollback(name: string, namespace: string, revision: number) {
const response = await helmReleasesApi.rollback(name, namespace, revision); const response = await helmReleasesApi.rollback(name, namespace, revision);
if (this.isLoaded) this.loadAll(); if (this.isLoaded) this.loadFromContextNamespaces();
return response; return response;
} }

View File

@ -14,11 +14,13 @@ import { ItemListLayout } from "../item-object-list/item-list-layout";
import { HelmReleaseMenu } from "./release-menu"; import { HelmReleaseMenu } from "./release-menu";
import { secretsStore } from "../+config-secrets/secrets.store"; import { secretsStore } from "../+config-secrets/secrets.store";
enum sortBy { enum columnId {
name = "name", name = "name",
namespace = "namespace", namespace = "namespace",
revision = "revision", revision = "revision",
chart = "chart", chart = "chart",
version = "version",
appVersion = "app-version",
status = "status", status = "status",
updated = "update" updated = "update"
} }
@ -81,16 +83,18 @@ export class HelmReleases extends Component<Props> {
return ( return (
<> <>
<ItemListLayout <ItemListLayout
isConfigurable
tableId="helm_releases"
className="HelmReleases" className="HelmReleases"
store={releaseStore} store={releaseStore}
dependentStores={[secretsStore]} dependentStores={[secretsStore]}
sortingCallbacks={{ sortingCallbacks={{
[sortBy.name]: (release: HelmRelease) => release.getName(), [columnId.name]: (release: HelmRelease) => release.getName(),
[sortBy.namespace]: (release: HelmRelease) => release.getNs(), [columnId.namespace]: (release: HelmRelease) => release.getNs(),
[sortBy.revision]: (release: HelmRelease) => release.getRevision(), [columnId.revision]: (release: HelmRelease) => release.getRevision(),
[sortBy.chart]: (release: HelmRelease) => release.getChart(), [columnId.chart]: (release: HelmRelease) => release.getChart(),
[sortBy.status]: (release: HelmRelease) => release.getStatus(), [columnId.status]: (release: HelmRelease) => release.getStatus(),
[sortBy.updated]: (release: HelmRelease) => release.getUpdated(false, false), [columnId.updated]: (release: HelmRelease) => release.getUpdated(false, false),
}} }}
searchFilters={[ searchFilters={[
(release: HelmRelease) => release.getName(), (release: HelmRelease) => release.getName(),
@ -101,14 +105,14 @@ export class HelmReleases extends Component<Props> {
]} ]}
renderHeaderTitle="Releases" renderHeaderTitle="Releases"
renderTableHeader={[ renderTableHeader={[
{ title: "Name", className: "name", sortBy: sortBy.name }, { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
{ title: "Chart", className: "chart", sortBy: sortBy.chart }, { title: "Chart", className: "chart", sortBy: columnId.chart, id: columnId.chart },
{ title: "Revision", className: "revision", sortBy: sortBy.revision }, { title: "Revision", className: "revision", sortBy: columnId.revision, id: columnId.revision },
{ title: "Version", className: "version" }, { title: "Version", className: "version", id: columnId.version },
{ title: "App Version", className: "app-version" }, { title: "App Version", className: "app-version", id: columnId.appVersion },
{ title: "Status", className: "status", sortBy: sortBy.status }, { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status },
{ title: "Updated", className: "updated", sortBy: sortBy.updated }, { title: "Updated", className: "updated", sortBy: columnId.updated, id: columnId.updated },
]} ]}
renderTableContents={(release: HelmRelease) => { renderTableContents={(release: HelmRelease) => {
const version = release.getVersion(); const version = release.getVersion();

View File

@ -0,0 +1,18 @@
import { navigate } from "../../navigation";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { helmChartsURL } from "../+apps-helm-charts";
import { releaseURL } from "../+apps-releases";
commandRegistry.add({
id: "cluster.viewHelmCharts",
title: "Cluster: View Helm Charts",
scope: "cluster",
action: () => navigate(helmChartsURL())
});
commandRegistry.add({
id: "cluster.viewHelmReleases",
title: "Cluster: View Helm Releases",
scope: "cluster",
action: () => navigate(releaseURL())
});

View File

@ -1,2 +1,3 @@
export * from "./apps"; export * from "./apps";
export * from "./apps.route"; export * from "./apps.route";
export * from "./apps.command";

View File

@ -0,0 +1,16 @@
import { navigate } from "../../navigation";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { clusterSettingsURL } from "./cluster-settings.route";
import { clusterStore } from "../../../common/cluster-store";
commandRegistry.add({
id: "cluster.viewCurrentClusterSettings",
title: "Cluster: View Settings",
scope: "global",
action: () => navigate(clusterSettingsURL({
params: {
clusterId: clusterStore.active.id
}
})),
isActive: (context) => !!context.cluster
});

View File

@ -1,7 +1,5 @@
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Link } from "react-router-dom";
import { workspacesURL } from "../../+workspaces";
import { workspaceStore } from "../../../../common/workspace-store"; import { workspaceStore } from "../../../../common/workspace-store";
import { Cluster } from "../../../../main/cluster"; import { Cluster } from "../../../../main/cluster";
import { Select } from "../../../components/select"; import { Select } from "../../../components/select";
@ -18,10 +16,7 @@ export class ClusterWorkspaceSetting extends React.Component<Props> {
<> <>
<SubTitle title="Cluster Workspace"/> <SubTitle title="Cluster Workspace"/>
<p> <p>
Define cluster{" "} Define cluster workspace.
<Link to={workspacesURL()}>
workspace
</Link>.
</p> </p>
<Select <Select
value={this.props.cluster.workspace} value={this.props.cluster.workspace}

View File

@ -1,2 +1,3 @@
export * from "./cluster-settings.route"; export * from "./cluster-settings.route";
export * from "./cluster-settings"; export * from "./cluster-settings";
export * from "./cluster-settings.command";

View File

@ -23,11 +23,13 @@ interface IWarning extends ItemObject {
kind: string; kind: string;
message: string; message: string;
selfLink: string; selfLink: string;
age: string | number;
} }
enum sortBy { enum sortBy {
type = "type", type = "type",
object = "object" object = "object",
age = "age",
} }
@observer @observer
@ -35,6 +37,7 @@ export class ClusterIssues extends React.Component<Props> {
private sortCallbacks = { private sortCallbacks = {
[sortBy.type]: (warning: IWarning) => warning.kind, [sortBy.type]: (warning: IWarning) => warning.kind,
[sortBy.object]: (warning: IWarning) => warning.getName(), [sortBy.object]: (warning: IWarning) => warning.getName(),
[sortBy.age]: (warning: IWarning) => warning.age || "",
}; };
@computed get warnings() { @computed get warnings() {
@ -42,15 +45,16 @@ export class ClusterIssues extends React.Component<Props> {
// Node bad conditions // Node bad conditions
nodesStore.items.forEach(node => { nodesStore.items.forEach(node => {
const { kind, selfLink, getId, getName } = node; const { kind, selfLink, getId, getName, getAge } = node;
node.getWarningConditions().forEach(({ message }) => { node.getWarningConditions().forEach(({ message }) => {
warnings.push({ warnings.push({
kind, age: getAge(),
getId, getId,
getName, getName,
selfLink, kind,
message, message,
selfLink,
}); });
}); });
}); });
@ -59,12 +63,13 @@ export class ClusterIssues extends React.Component<Props> {
const events = eventStore.getWarnings(); const events = eventStore.getWarnings();
events.forEach(error => { events.forEach(error => {
const { message, involvedObject } = error; const { message, involvedObject, getAge } = error;
const { uid, name, kind } = involvedObject; const { uid, name, kind } = involvedObject;
warnings.push({ warnings.push({
getId: () => uid, getId: () => uid,
getName: () => name, getName: () => name,
age: getAge(),
message, message,
kind, kind,
selfLink: lookupApiLink(involvedObject, error), selfLink: lookupApiLink(involvedObject, error),
@ -78,7 +83,7 @@ export class ClusterIssues extends React.Component<Props> {
getTableRow(uid: string) { getTableRow(uid: string) {
const { warnings } = this; const { warnings } = this;
const warning = warnings.find(warn => warn.getId() == uid); const warning = warnings.find(warn => warn.getId() == uid);
const { getId, getName, message, kind, selfLink } = warning; const { getId, getName, message, kind, selfLink, age } = warning;
return ( return (
<TableRow <TableRow
@ -96,6 +101,9 @@ export class ClusterIssues extends React.Component<Props> {
<TableCell className="kind"> <TableCell className="kind">
{kind} {kind}
</TableCell> </TableCell>
<TableCell className="age">
{age}
</TableCell>
</TableRow> </TableRow>
); );
} }
@ -139,6 +147,7 @@ export class ClusterIssues extends React.Component<Props> {
<TableCell className="message">Message</TableCell> <TableCell className="message">Message</TableCell>
<TableCell className="object" sortBy={sortBy.object}>Object</TableCell> <TableCell className="object" sortBy={sortBy.object}>Object</TableCell>
<TableCell className="kind" sortBy={sortBy.type}>Type</TableCell> <TableCell className="kind" sortBy={sortBy.type}>Type</TableCell>
<TableCell className="timestamp" sortBy={sortBy.age}>Age</TableCell>
</TableHead> </TableHead>
</Table> </Table>
</> </>

View File

@ -3,13 +3,9 @@ import "./cluster-overview.scss";
import React from "react"; import React from "react";
import { reaction } from "mobx"; import { reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { eventStore } from "../+events/event.store";
import { nodesStore } from "../+nodes/nodes.store"; import { nodesStore } from "../+nodes/nodes.store";
import { podsStore } from "../+workloads-pods/pods.store"; import { podsStore } from "../+workloads-pods/pods.store";
import { getHostedCluster } from "../../../common/cluster-store"; import { getHostedCluster } from "../../../common/cluster-store";
import { isAllowedResource } from "../../../common/rbac";
import { KubeObjectStore } from "../../kube-object.store";
import { interval } from "../../utils"; import { interval } from "../../utils";
import { TabLayout } from "../layout/tab-layout"; import { TabLayout } from "../layout/tab-layout";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
@ -20,42 +16,24 @@ import { ClusterPieCharts } from "./cluster-pie-charts";
@observer @observer
export class ClusterOverview extends React.Component { export class ClusterOverview extends React.Component {
private stores: KubeObjectStore<any>[] = []; private metricPoller = interval(60, () => this.loadMetrics());
private subscribers: Array<() => void> = [];
private metricPoller = interval(60, this.loadMetrics);
@disposeOnUnmount
fetchMetrics = reaction(
() => clusterOverviewStore.metricNodeRole, // Toggle Master/Worker node switcher
() => this.metricPoller.restart(true)
);
loadMetrics() { loadMetrics() {
getHostedCluster().available && clusterOverviewStore.loadMetrics(); getHostedCluster().available && clusterOverviewStore.loadMetrics();
} }
async componentDidMount() { componentDidMount() {
if (isAllowedResource("nodes")) { this.metricPoller.start(true);
this.stores.push(nodesStore);
}
if (isAllowedResource("pods")) { disposeOnUnmount(this, [
this.stores.push(podsStore); reaction(
} () => clusterOverviewStore.metricNodeRole, // Toggle Master/Worker node switcher
() => this.metricPoller.restart(true)
if (isAllowedResource("events")) { ),
this.stores.push(eventStore); ]);
}
await Promise.all(this.stores.map(store => store.loadAll()));
this.loadMetrics();
this.subscribers = this.stores.map(store => store.subscribe());
this.metricPoller.start();
} }
componentWillUnmount() { componentWillUnmount() {
this.subscribers.forEach(dispose => dispose()); // unsubscribe all
this.metricPoller.stop(); this.metricPoller.stop();
} }

View File

@ -11,13 +11,15 @@ import { Badge } from "../badge";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { KubeObjectStatusIcon } from "../kube-object-status-icon";
enum sortBy { enum columnId {
name = "name", name = "name",
namespace = "namespace", namespace = "namespace",
metrics = "metrics",
minPods = "min-pods", minPods = "min-pods",
maxPods = "max-pods", maxPods = "max-pods",
replicas = "replicas", replicas = "replicas",
age = "age", age = "age",
status = "status"
} }
interface Props extends RouteComponentProps<IHpaRouteParams> { interface Props extends RouteComponentProps<IHpaRouteParams> {
@ -37,28 +39,30 @@ export class HorizontalPodAutoscalers extends React.Component<Props> {
render() { render() {
return ( return (
<KubeObjectListLayout <KubeObjectListLayout
isConfigurable
tableId="configuration_hpa"
className="HorizontalPodAutoscalers" store={hpaStore} className="HorizontalPodAutoscalers" store={hpaStore}
sortingCallbacks={{ sortingCallbacks={{
[sortBy.name]: (item: HorizontalPodAutoscaler) => item.getName(), [columnId.name]: (item: HorizontalPodAutoscaler) => item.getName(),
[sortBy.namespace]: (item: HorizontalPodAutoscaler) => item.getNs(), [columnId.namespace]: (item: HorizontalPodAutoscaler) => item.getNs(),
[sortBy.minPods]: (item: HorizontalPodAutoscaler) => item.getMinPods(), [columnId.minPods]: (item: HorizontalPodAutoscaler) => item.getMinPods(),
[sortBy.maxPods]: (item: HorizontalPodAutoscaler) => item.getMaxPods(), [columnId.maxPods]: (item: HorizontalPodAutoscaler) => item.getMaxPods(),
[sortBy.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas() [columnId.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas()
}} }}
searchFilters={[ searchFilters={[
(item: HorizontalPodAutoscaler) => item.getSearchFields() (item: HorizontalPodAutoscaler) => item.getSearchFields()
]} ]}
renderHeaderTitle="Horizontal Pod Autoscalers" renderHeaderTitle="Horizontal Pod Autoscalers"
renderTableHeader={[ renderTableHeader={[
{ title: "Name", className: "name", sortBy: sortBy.name }, { title: "Name", className: "name", sortBy: columnId.name },
{ className: "warning" }, { className: "warning", showWithColumn: columnId.name },
{ title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
{ title: "Metrics", className: "metrics" }, { title: "Metrics", className: "metrics", id: columnId.metrics },
{ title: "Min Pods", className: "min-pods", sortBy: sortBy.minPods }, { title: "Min Pods", className: "min-pods", sortBy: columnId.minPods, id: columnId.minPods },
{ title: "Max Pods", className: "max-pods", sortBy: sortBy.maxPods }, { title: "Max Pods", className: "max-pods", sortBy: columnId.maxPods, id: columnId.maxPods },
{ title: "Replicas", className: "replicas", sortBy: sortBy.replicas }, { title: "Replicas", className: "replicas", sortBy: columnId.replicas, id: columnId.replicas },
{ title: "Age", className: "age", sortBy: sortBy.age }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
{ title: "Status", className: "status" }, { title: "Status", className: "status", id: columnId.status },
]} ]}
renderTableContents={(hpa: HorizontalPodAutoscaler) => [ renderTableContents={(hpa: HorizontalPodAutoscaler) => [
hpa.getName(), hpa.getName(),

View File

@ -9,7 +9,7 @@ import React from "react";
import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import { LimitRange } from "../../api/endpoints/limit-range.api"; import { LimitRange } from "../../api/endpoints/limit-range.api";
enum sortBy { enum columnId {
name = "name", name = "name",
namespace = "namespace", namespace = "namespace",
age = "age", age = "age",
@ -23,12 +23,14 @@ export class LimitRanges extends React.Component<Props> {
render() { render() {
return ( return (
<KubeObjectListLayout <KubeObjectListLayout
isConfigurable
tableId="configuration_limitranges"
className="LimitRanges" className="LimitRanges"
store={limitRangeStore} store={limitRangeStore}
sortingCallbacks={{ sortingCallbacks={{
[sortBy.name]: (item: LimitRange) => item.getName(), [columnId.name]: (item: LimitRange) => item.getName(),
[sortBy.namespace]: (item: LimitRange) => item.getNs(), [columnId.namespace]: (item: LimitRange) => item.getNs(),
[sortBy.age]: (item: LimitRange) => item.metadata.creationTimestamp, [columnId.age]: (item: LimitRange) => item.metadata.creationTimestamp,
}} }}
searchFilters={[ searchFilters={[
(item: LimitRange) => item.getName(), (item: LimitRange) => item.getName(),
@ -36,10 +38,10 @@ export class LimitRanges extends React.Component<Props> {
]} ]}
renderHeaderTitle={"Limit Ranges"} renderHeaderTitle={"Limit Ranges"}
renderTableHeader={[ renderTableHeader={[
{ title: "Name", className: "name", sortBy: sortBy.name }, { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ className: "warning" }, { className: "warning", showWithColumn: columnId.name },
{ title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
{ title: "Age", className: "age", sortBy: sortBy.age }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
]} ]}
renderTableContents={(limitRange: LimitRange) => [ renderTableContents={(limitRange: LimitRange) => [
limitRange.getName(), limitRange.getName(),

View File

@ -9,7 +9,7 @@ import { KubeObjectListLayout } from "../kube-object";
import { IConfigMapsRouteParams } from "./config-maps.route"; import { IConfigMapsRouteParams } from "./config-maps.route";
import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { KubeObjectStatusIcon } from "../kube-object-status-icon";
enum sortBy { enum columnId {
name = "name", name = "name",
namespace = "namespace", namespace = "namespace",
keys = "keys", keys = "keys",
@ -24,12 +24,14 @@ export class ConfigMaps extends React.Component<Props> {
render() { render() {
return ( return (
<KubeObjectListLayout <KubeObjectListLayout
isConfigurable
tableId="configuration_configmaps"
className="ConfigMaps" store={configMapsStore} className="ConfigMaps" store={configMapsStore}
sortingCallbacks={{ sortingCallbacks={{
[sortBy.name]: (item: ConfigMap) => item.getName(), [columnId.name]: (item: ConfigMap) => item.getName(),
[sortBy.namespace]: (item: ConfigMap) => item.getNs(), [columnId.namespace]: (item: ConfigMap) => item.getNs(),
[sortBy.keys]: (item: ConfigMap) => item.getKeys(), [columnId.keys]: (item: ConfigMap) => item.getKeys(),
[sortBy.age]: (item: ConfigMap) => item.metadata.creationTimestamp, [columnId.age]: (item: ConfigMap) => item.metadata.creationTimestamp,
}} }}
searchFilters={[ searchFilters={[
(item: ConfigMap) => item.getSearchFields(), (item: ConfigMap) => item.getSearchFields(),
@ -37,11 +39,11 @@ export class ConfigMaps extends React.Component<Props> {
]} ]}
renderHeaderTitle="Config Maps" renderHeaderTitle="Config Maps"
renderTableHeader={[ renderTableHeader={[
{ title: "Name", className: "name", sortBy: sortBy.name }, { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ className: "warning" }, { className: "warning", showWithColumn: columnId.name },
{ title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
{ title: "Keys", className: "keys", sortBy: sortBy.keys }, { title: "Keys", className: "keys", sortBy: columnId.keys, id: columnId.keys },
{ title: "Age", className: "age", sortBy: sortBy.age }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
]} ]}
renderTableContents={(configMap: ConfigMap) => [ renderTableContents={(configMap: ConfigMap) => [
configMap.getName(), configMap.getName(),

View File

@ -7,7 +7,7 @@ import { PodDisruptionBudget } from "../../api/endpoints/poddisruptionbudget.api
import { KubeObjectDetailsProps, KubeObjectListLayout } from "../kube-object"; import { KubeObjectDetailsProps, KubeObjectListLayout } from "../kube-object";
import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { KubeObjectStatusIcon } from "../kube-object-status-icon";
enum sortBy { enum columnId {
name = "name", name = "name",
namespace = "namespace", namespace = "namespace",
minAvailable = "min-available", minAvailable = "min-available",
@ -25,30 +25,32 @@ export class PodDisruptionBudgets extends React.Component<Props> {
render() { render() {
return ( return (
<KubeObjectListLayout <KubeObjectListLayout
isConfigurable
tableId="configuration_distribution_budgets"
className="PodDisruptionBudgets" className="PodDisruptionBudgets"
store={podDisruptionBudgetsStore} store={podDisruptionBudgetsStore}
sortingCallbacks={{ sortingCallbacks={{
[sortBy.name]: (pdb: PodDisruptionBudget) => pdb.getName(), [columnId.name]: (pdb: PodDisruptionBudget) => pdb.getName(),
[sortBy.namespace]: (pdb: PodDisruptionBudget) => pdb.getNs(), [columnId.namespace]: (pdb: PodDisruptionBudget) => pdb.getNs(),
[sortBy.minAvailable]: (pdb: PodDisruptionBudget) => pdb.getMinAvailable(), [columnId.minAvailable]: (pdb: PodDisruptionBudget) => pdb.getMinAvailable(),
[sortBy.maxUnavailable]: (pdb: PodDisruptionBudget) => pdb.getMaxUnavailable(), [columnId.maxUnavailable]: (pdb: PodDisruptionBudget) => pdb.getMaxUnavailable(),
[sortBy.currentHealthy]: (pdb: PodDisruptionBudget) => pdb.getCurrentHealthy(), [columnId.currentHealthy]: (pdb: PodDisruptionBudget) => pdb.getCurrentHealthy(),
[sortBy.desiredHealthy]: (pdb: PodDisruptionBudget) => pdb.getDesiredHealthy(), [columnId.desiredHealthy]: (pdb: PodDisruptionBudget) => pdb.getDesiredHealthy(),
[sortBy.age]: (pdb: PodDisruptionBudget) => pdb.getAge(), [columnId.age]: (pdb: PodDisruptionBudget) => pdb.getAge(),
}} }}
searchFilters={[ searchFilters={[
(pdb: PodDisruptionBudget) => pdb.getSearchFields(), (pdb: PodDisruptionBudget) => pdb.getSearchFields(),
]} ]}
renderHeaderTitle="Pod Disruption Budgets" renderHeaderTitle="Pod Disruption Budgets"
renderTableHeader={[ renderTableHeader={[
{ title: "Name", className: "name", sortBy: sortBy.name }, { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ className: "warning" }, { className: "warning", showWithColumn: columnId.name },
{ title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
{ title: "Min Available", className: "min-available", sortBy: sortBy.minAvailable }, { title: "Min Available", className: "min-available", sortBy: columnId.minAvailable, id: columnId.minAvailable },
{ title: "Max Unavailable", className: "max-unavailable", sortBy: sortBy.maxUnavailable }, { title: "Max Unavailable", className: "max-unavailable", sortBy: columnId.maxUnavailable, id: columnId.maxUnavailable },
{ title: "Current Healthy", className: "current-healthy", sortBy: sortBy.currentHealthy }, { title: "Current Healthy", className: "current-healthy", sortBy: columnId.currentHealthy, id: columnId.currentHealthy },
{ title: "Desired Healthy", className: "desired-healthy", sortBy: sortBy.desiredHealthy }, { title: "Desired Healthy", className: "desired-healthy", sortBy: columnId.desiredHealthy, id: columnId.desiredHealthy },
{ title: "Age", className: "age", sortBy: sortBy.age }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
]} ]}
renderTableContents={(pdb: PodDisruptionBudget) => { renderTableContents={(pdb: PodDisruptionBudget) => {
return [ return [

View File

@ -10,7 +10,7 @@ import { resourceQuotaStore } from "./resource-quotas.store";
import { IResourceQuotaRouteParams } from "./resource-quotas.route"; import { IResourceQuotaRouteParams } from "./resource-quotas.route";
import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { KubeObjectStatusIcon } from "../kube-object-status-icon";
enum sortBy { enum columnId {
name = "name", name = "name",
namespace = "namespace", namespace = "namespace",
age = "age" age = "age"
@ -25,11 +25,13 @@ export class ResourceQuotas extends React.Component<Props> {
return ( return (
<> <>
<KubeObjectListLayout <KubeObjectListLayout
isConfigurable
tableId="configuration_quotas"
className="ResourceQuotas" store={resourceQuotaStore} className="ResourceQuotas" store={resourceQuotaStore}
sortingCallbacks={{ sortingCallbacks={{
[sortBy.name]: (item: ResourceQuota) => item.getName(), [columnId.name]: (item: ResourceQuota) => item.getName(),
[sortBy.namespace]: (item: ResourceQuota) => item.getNs(), [columnId.namespace]: (item: ResourceQuota) => item.getNs(),
[sortBy.age]: (item: ResourceQuota) => item.metadata.creationTimestamp, [columnId.age]: (item: ResourceQuota) => item.metadata.creationTimestamp,
}} }}
searchFilters={[ searchFilters={[
(item: ResourceQuota) => item.getSearchFields(), (item: ResourceQuota) => item.getSearchFields(),
@ -37,10 +39,10 @@ export class ResourceQuotas extends React.Component<Props> {
]} ]}
renderHeaderTitle="Resource Quotas" renderHeaderTitle="Resource Quotas"
renderTableHeader={[ renderTableHeader={[
{ title: "Name", className: "name", sortBy: sortBy.name }, { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ className: "warning" }, { className: "warning", showWithColumn: columnId.name },
{ title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
{ title: "Age", className: "age", sortBy: sortBy.age }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
]} ]}
renderTableContents={(resourceQuota: ResourceQuota) => [ renderTableContents={(resourceQuota: ResourceQuota) => [
resourceQuota.getName(), resourceQuota.getName(),

View File

@ -11,7 +11,7 @@ import { Badge } from "../badge";
import { secretsStore } from "./secrets.store"; import { secretsStore } from "./secrets.store";
import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { KubeObjectStatusIcon } from "../kube-object-status-icon";
enum sortBy { enum columnId {
name = "name", name = "name",
namespace = "namespace", namespace = "namespace",
labels = "labels", labels = "labels",
@ -29,14 +29,16 @@ export class Secrets extends React.Component<Props> {
return ( return (
<> <>
<KubeObjectListLayout <KubeObjectListLayout
isConfigurable
tableId="configuration_secrets"
className="Secrets" store={secretsStore} className="Secrets" store={secretsStore}
sortingCallbacks={{ sortingCallbacks={{
[sortBy.name]: (item: Secret) => item.getName(), [columnId.name]: (item: Secret) => item.getName(),
[sortBy.namespace]: (item: Secret) => item.getNs(), [columnId.namespace]: (item: Secret) => item.getNs(),
[sortBy.labels]: (item: Secret) => item.getLabels(), [columnId.labels]: (item: Secret) => item.getLabels(),
[sortBy.keys]: (item: Secret) => item.getKeys(), [columnId.keys]: (item: Secret) => item.getKeys(),
[sortBy.type]: (item: Secret) => item.type, [columnId.type]: (item: Secret) => item.type,
[sortBy.age]: (item: Secret) => item.metadata.creationTimestamp, [columnId.age]: (item: Secret) => item.metadata.creationTimestamp,
}} }}
searchFilters={[ searchFilters={[
(item: Secret) => item.getSearchFields(), (item: Secret) => item.getSearchFields(),
@ -44,13 +46,13 @@ export class Secrets extends React.Component<Props> {
]} ]}
renderHeaderTitle="Secrets" renderHeaderTitle="Secrets"
renderTableHeader={[ renderTableHeader={[
{ title: "Name", className: "name", sortBy: sortBy.name }, { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ className: "warning" }, { className: "warning", showWithColumn: columnId.name },
{ title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
{ title: "Labels", className: "labels", sortBy: sortBy.labels }, { title: "Labels", className: "labels", sortBy: columnId.labels, id: columnId.labels },
{ title: "Keys", className: "keys", sortBy: sortBy.keys }, { title: "Keys", className: "keys", sortBy: columnId.keys, id: columnId.keys },
{ title: "Type", className: "type", sortBy: sortBy.type }, { title: "Type", className: "type", sortBy: columnId.type, id: columnId.type },
{ title: "Age", className: "age", sortBy: sortBy.age }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
]} ]}
renderTableContents={(secret: Secret) => [ renderTableContents={(secret: Secret) => [
secret.getName(), secret.getName(),

View File

@ -0,0 +1,50 @@
import { navigate } from "../../navigation";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { configMapsURL } from "../+config-maps";
import { secretsURL } from "../+config-secrets";
import { resourceQuotaURL } from "../+config-resource-quotas";
import { limitRangeURL } from "../+config-limit-ranges";
import { hpaURL } from "../+config-autoscalers";
import { pdbURL } from "../+config-pod-disruption-budgets";
commandRegistry.add({
id: "cluster.viewConfigMaps",
title: "Cluster: View ConfigMaps",
scope: "cluster",
action: () => navigate(configMapsURL())
});
commandRegistry.add({
id: "cluster.viewSecrets",
title: "Cluster: View Secrets",
scope: "cluster",
action: () => navigate(secretsURL())
});
commandRegistry.add({
id: "cluster.viewResourceQuotas",
title: "Cluster: View ResourceQuotas",
scope: "cluster",
action: () => navigate(resourceQuotaURL())
});
commandRegistry.add({
id: "cluster.viewLimitRanges",
title: "Cluster: View LimitRanges",
scope: "cluster",
action: () => navigate(limitRangeURL())
});
commandRegistry.add({
id: "cluster.viewHorizontalPodAutoscalers",
title: "Cluster: View HorizontalPodAutoscalers (HPA)",
scope: "cluster",
action: () => navigate(hpaURL())
});
commandRegistry.add({
id: "cluster.viewPodDisruptionBudget",
title: "Cluster: View PodDisruptionBudgets",
scope: "cluster",
action: () => navigate(pdbURL())
});

View File

@ -1,2 +1,3 @@
export * from "./config.route"; export * from "./config.route";
export * from "./config"; export * from "./config";
export * from "./config.command";

View File

@ -19,7 +19,7 @@ export const crdGroupsUrlParam = createPageParam<string[]>({
defaultValue: [], defaultValue: [],
}); });
enum sortBy { enum columnId {
kind = "kind", kind = "kind",
group = "group", group = "group",
version = "version", version = "version",
@ -47,14 +47,16 @@ export class CrdList extends React.Component {
render() { render() {
const selectedGroups = this.groups; const selectedGroups = this.groups;
const sortingCallbacks = { const sortingCallbacks = {
[sortBy.kind]: (crd: CustomResourceDefinition) => crd.getResourceKind(), [columnId.kind]: (crd: CustomResourceDefinition) => crd.getResourceKind(),
[sortBy.group]: (crd: CustomResourceDefinition) => crd.getGroup(), [columnId.group]: (crd: CustomResourceDefinition) => crd.getGroup(),
[sortBy.version]: (crd: CustomResourceDefinition) => crd.getVersion(), [columnId.version]: (crd: CustomResourceDefinition) => crd.getVersion(),
[sortBy.scope]: (crd: CustomResourceDefinition) => crd.getScope(), [columnId.scope]: (crd: CustomResourceDefinition) => crd.getScope(),
}; };
return ( return (
<KubeObjectListLayout <KubeObjectListLayout
isConfigurable
tableId="crd"
className="CrdList" className="CrdList"
isClusterScoped={true} isClusterScoped={true}
store={crdStore} store={crdStore}
@ -97,11 +99,11 @@ export class CrdList extends React.Component {
}; };
}} }}
renderTableHeader={[ renderTableHeader={[
{ title: "Resource", className: "kind", sortBy: sortBy.kind }, { title: "Resource", className: "kind", sortBy: columnId.kind, id: columnId.kind },
{ title: "Group", className: "group", sortBy: sortBy.group }, { title: "Group", className: "group", sortBy: columnId.group, id: columnId.group },
{ title: "Version", className: "version", sortBy: sortBy.group }, { title: "Version", className: "version", sortBy: columnId.version, id: columnId.version },
{ title: "Scope", className: "scope", sortBy: sortBy.scope }, { title: "Scope", className: "scope", sortBy: columnId.scope, id: columnId.scope },
{ title: "Age", className: "age", sortBy: sortBy.age }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
]} ]}
renderTableContents={(crd: CustomResourceDefinition) => [ renderTableContents={(crd: CustomResourceDefinition) => [
<Link key="link" to={crd.getResourceUrl()} onClick={stopPropagation}> <Link key="link" to={crd.getResourceUrl()} onClick={stopPropagation}>

View File

@ -16,7 +16,7 @@ import { parseJsonPath } from "../../utils/jsonPath";
interface Props extends RouteComponentProps<ICRDRouteParams> { interface Props extends RouteComponentProps<ICRDRouteParams> {
} }
enum sortBy { enum columnId {
name = "name", name = "name",
namespace = "namespace", namespace = "namespace",
age = "age", age = "age",
@ -30,7 +30,7 @@ export class CrdResources extends React.Component<Props> {
const { store } = this; const { store } = this;
if (store && !store.isLoading && !store.isLoaded) { if (store && !store.isLoading && !store.isLoaded) {
store.loadAll(); store.reloadAll();
} }
}) })
]); ]);
@ -55,9 +55,9 @@ export class CrdResources extends React.Component<Props> {
const isNamespaced = crd.isNamespaced(); const isNamespaced = crd.isNamespaced();
const extraColumns = crd.getPrinterColumns(false); // Cols with priority bigger than 0 are shown in details const extraColumns = crd.getPrinterColumns(false); // Cols with priority bigger than 0 are shown in details
const sortingCallbacks: { [sortBy: string]: TableSortCallback } = { const sortingCallbacks: { [sortBy: string]: TableSortCallback } = {
[sortBy.name]: (item: KubeObject) => item.getName(), [columnId.name]: (item: KubeObject) => item.getName(),
[sortBy.namespace]: (item: KubeObject) => item.getNs(), [columnId.namespace]: (item: KubeObject) => item.getNs(),
[sortBy.age]: (item: KubeObject) => item.metadata.creationTimestamp, [columnId.age]: (item: KubeObject) => item.metadata.creationTimestamp,
}; };
extraColumns.forEach(column => { extraColumns.forEach(column => {
@ -66,6 +66,8 @@ export class CrdResources extends React.Component<Props> {
return ( return (
<KubeObjectListLayout <KubeObjectListLayout
isConfigurable
tableId="crd_resources"
className="CrdResources" className="CrdResources"
isClusterScoped={!isNamespaced} isClusterScoped={!isNamespaced}
store={store} store={store}
@ -75,18 +77,19 @@ export class CrdResources extends React.Component<Props> {
]} ]}
renderHeaderTitle={crd.getResourceTitle()} renderHeaderTitle={crd.getResourceTitle()}
renderTableHeader={[ renderTableHeader={[
{ title: "Name", className: "name", sortBy: sortBy.name }, { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
isNamespaced && { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, isNamespaced && { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
...extraColumns.map(column => { ...extraColumns.map(column => {
const { name } = column; const { name } = column;
return { return {
title: name, title: name,
className: name.toLowerCase(), className: name.toLowerCase(),
sortBy: name sortBy: name,
id: name
}; };
}), }),
{ title: "Age", className: "age", sortBy: sortBy.age }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
]} ]}
renderTableContents={(crdInstance: KubeObject) => [ renderTableContents={(crdInstance: KubeObject) => [
crdInstance.getName(), crdInstance.getName(),
@ -94,7 +97,7 @@ export class CrdResources extends React.Component<Props> {
...extraColumns.map((column) => { ...extraColumns.map((column) => {
let value = jsonPath.value(crdInstance, parseJsonPath(column.jsonPath.slice(1))); let value = jsonPath.value(crdInstance, parseJsonPath(column.jsonPath.slice(1)));
if (Array.isArray(value) || typeof value === "object") { if (Array.isArray(value) || typeof value === "object") {
value = JSON.stringify(value); value = JSON.stringify(value);
} }

View File

@ -52,6 +52,10 @@ export class EventStore extends KubeObjectStore<KubeEvent> {
return compact(eventsWithError); return compact(eventsWithError);
} }
getWarningsCount() {
return this.getWarnings().length;
}
} }
export const eventStore = new EventStore(); export const eventStore = new EventStore();

View File

@ -12,11 +12,13 @@ import { cssNames, IClassName, stopPropagation } from "../../utils";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { lookupApiLink } from "../../api/kube-api"; import { lookupApiLink } from "../../api/kube-api";
enum sortBy { enum columnId {
message = "message",
namespace = "namespace", namespace = "namespace",
object = "object", object = "object",
type = "type", type = "type",
count = "count", count = "count",
source = "source",
age = "age", age = "age",
} }
@ -39,15 +41,17 @@ export class Events extends React.Component<Props> {
const events = ( const events = (
<KubeObjectListLayout <KubeObjectListLayout
{...layoutProps} {...layoutProps}
isConfigurable
tableId="events"
className={cssNames("Events", className, { compact })} className={cssNames("Events", className, { compact })}
store={eventStore} store={eventStore}
isSelectable={false} isSelectable={false}
sortingCallbacks={{ sortingCallbacks={{
[sortBy.namespace]: (event: KubeEvent) => event.getNs(), [columnId.namespace]: (event: KubeEvent) => event.getNs(),
[sortBy.type]: (event: KubeEvent) => event.involvedObject.kind, [columnId.type]: (event: KubeEvent) => event.involvedObject.kind,
[sortBy.object]: (event: KubeEvent) => event.involvedObject.name, [columnId.object]: (event: KubeEvent) => event.involvedObject.name,
[sortBy.count]: (event: KubeEvent) => event.count, [columnId.count]: (event: KubeEvent) => event.count,
[sortBy.age]: (event: KubeEvent) => event.metadata.creationTimestamp, [columnId.age]: (event: KubeEvent) => event.metadata.creationTimestamp,
}} }}
searchFilters={[ searchFilters={[
(event: KubeEvent) => event.getSearchFields(), (event: KubeEvent) => event.getSearchFields(),
@ -72,13 +76,13 @@ export class Events extends React.Component<Props> {
}) })
)} )}
renderTableHeader={[ renderTableHeader={[
{ title: "Message", className: "message" }, { title: "Message", className: "message", id: columnId.message },
{ title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
{ title: "Type", className: "type", sortBy: sortBy.type }, { title: "Type", className: "type", sortBy: columnId.type, id: columnId.type },
{ title: "Involved Object", className: "object", sortBy: sortBy.object }, { title: "Involved Object", className: "object", sortBy: columnId.object, id: columnId.object },
{ title: "Source", className: "source" }, { title: "Source", className: "source", id: columnId.source },
{ title: "Count", className: "count", sortBy: sortBy.count }, { title: "Count", className: "count", sortBy: columnId.count, id: columnId.count },
{ title: "Age", className: "age", sortBy: sortBy.age }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age },
]} ]}
renderTableContents={(event: KubeEvent) => { renderTableContents={(event: KubeEvent) => {
const { involvedObject, type, message } = event; const { involvedObject, type, message } = event;

View File

@ -14,7 +14,7 @@ export interface KubeEventDetailsProps {
@observer @observer
export class KubeEventDetails extends React.Component<KubeEventDetailsProps> { export class KubeEventDetails extends React.Component<KubeEventDetailsProps> {
async componentDidMount() { async componentDidMount() {
eventStore.loadAll(); eventStore.reloadAll();
} }
render() { render() {

Some files were not shown because too many files have changed in this diff Show More