diff --git a/.azure-pipelines.yml b/.azure-pipelines.yml index 6bb07489ec..d7ed058fa3 100644 --- a/.azure-pipelines.yml +++ b/.azure-pipelines.yml @@ -58,6 +58,7 @@ jobs: - script: make test-extensions displayName: Run In-tree Extension tests - bash: | + set -e rm -rf extensions/telemetry make integration-win git checkout extensions/telemetry @@ -102,6 +103,7 @@ jobs: - script: make test-extensions displayName: Run In-tree Extension tests - bash: | + set -e rm -rf extensions/telemetry make integration-mac git checkout extensions/telemetry @@ -159,6 +161,7 @@ jobs: sudo chown -R $USER $HOME/.kube $HOME/.minikube displayName: Install integration test dependencies - bash: | + set -e rm -rf extensions/telemetry xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' make integration-linux git checkout extensions/telemetry diff --git a/.eslintrc.js b/.eslintrc.js index 3fd52c2465..57ee07348f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -46,6 +46,8 @@ module.exports = { "avoidEscape": true, "allowTemplateLiterals": true, }], + "linebreak-style": ["error", "unix"], + "eol-last": ["error", "always"], "semi": ["error", "always"], "object-shorthand": "error", "prefer-template": "error", @@ -101,6 +103,8 @@ module.exports = { }], "semi": "off", "@typescript-eslint/semi": ["error"], + "linebreak-style": ["error", "unix"], + "eol-last": ["error", "always"], "object-shorthand": "error", "prefer-template": "error", "template-curly-spacing": "error", @@ -162,6 +166,8 @@ module.exports = { }], "semi": "off", "@typescript-eslint/semi": ["error"], + "linebreak-style": ["error", "unix"], + "eol-last": ["error", "always"], "object-shorthand": "error", "prefer-template": "error", "template-curly-spacing": "error", diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0c6340d6a2..afbcc74367 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,7 +18,7 @@ jobs: uses: actions/setup-python@v2 with: python-version: '3.x' - + - name: Checkout Release from lens uses: actions/checkout@v2 with: @@ -83,12 +83,12 @@ jobs: mike deploy --push master - name: Get the release version - if: contains(github.ref, 'refs/tags/v') # && !github.event.release.prerelease (generate pre-release docs until Lens 4.0.0 is GA, see #1408) + if: contains(github.ref, 'refs/tags/v') && !github.event.release.prerelease id: get_version run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} - name: mkdocs deploy new release - if: contains(github.ref, 'refs/tags/v') # && !github.event.release.prerelease (generate pre-release docs until Lens 4.0.0 is GA, see #1408) + if: contains(github.ref, 'refs/tags/v') && !github.event.release.prerelease run: | mike deploy --push --update-aliases ${{ steps.get_version.outputs.VERSION }} latest mike set-default --push ${{ steps.get_version.outputs.VERSION }} diff --git a/Makefile b/Makefile index 362ef3b830..000682e039 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ endif binaries/client: yarn download-bins -node_modules: +node_modules: yarn.lock yarn install --frozen-lockfile yarn check --verify-tree --integrity diff --git a/__mocks__/imageMock.ts b/__mocks__/imageMock.ts index a099545376..f053ebf797 100644 --- a/__mocks__/imageMock.ts +++ b/__mocks__/imageMock.ts @@ -1 +1 @@ -module.exports = {}; \ No newline at end of file +module.exports = {}; diff --git a/__mocks__/styleMock.ts b/__mocks__/styleMock.ts index a099545376..f053ebf797 100644 --- a/__mocks__/styleMock.ts +++ b/__mocks__/styleMock.ts @@ -1 +1 @@ -module.exports = {}; \ No newline at end of file +module.exports = {}; diff --git a/docs/extensions/guides/images/clusterpagemenus.png b/docs/extensions/guides/images/clusterpagemenus.png new file mode 100644 index 0000000000..3ed1c79e5b Binary files /dev/null and b/docs/extensions/guides/images/clusterpagemenus.png differ diff --git a/docs/extensions/guides/images/globalpagemenus.png b/docs/extensions/guides/images/globalpagemenus.png new file mode 100644 index 0000000000..e986cc32e9 Binary files /dev/null and b/docs/extensions/guides/images/globalpagemenus.png differ diff --git a/docs/extensions/guides/renderer-extension.md b/docs/extensions/guides/renderer-extension.md index f64cb5fd49..c3ff953b2a 100644 --- a/docs/extensions/guides/renderer-extension.md +++ b/docs/extensions/guides/renderer-extension.md @@ -1,13 +1,26 @@ # Renderer Extension -The renderer extension api is the interface to Lens's renderer process (Lens runs in main and renderer processes). -It allows you to access, configure, and customize Lens data, add custom Lens UI elements, and generally run custom code in Lens's renderer process. -The custom Lens UI elements that can be added include global pages, cluster pages, cluster page menus, cluster features, app preferences, status bar items, KubeObject menu items, and KubeObject details items. -These UI elements are based on React components. +The Renderer Extension API is the interface to Lens's renderer process. Lens runs in both the main and renderer processes. The Renderer Extension API allows you to access, configure, and customize Lens data, add custom Lens UI elements, and run custom code in Lens's renderer process. + +The custom Lens UI elements that you can add include: + +* [Cluster pages](#clusterpages) +* [Cluster page menus](#clusterpagemenus) +* [Global pages](#globalpages) +* [Global page menus](#globalpagemenus) +* [Cluster features](#clusterfeatures) +* [App preferences](#apppreferences) +* [Status bar items](#statusbaritems) +* [KubeObject menu items](#kubeobjectmenuitems) +* [KubeObject detail items](#kubeobjectdetailitems) + +All UI elements are based on React components. ## `LensRendererExtension` Class -To create a renderer extension simply extend the `LensRendererExtension` class: +### `onActivate()` and `onDeactivate()` Methods + +To create a renderer extension, extend the `LensRendererExtension` class: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -23,21 +36,21 @@ export default class ExampleExtensionMain extends LensRendererExtension { } ``` -There are two methods that you can implement to facilitate running your custom code. -`onActivate()` is called when your extension has been successfully enabled. -By implementing `onActivate()` you can initiate your custom code. -`onDeactivate()` is called when the extension is disabled (typically from the [Lens Extensions Page]()) and when implemented gives you a chance to clean up after your extension, if necessary. -The example above simply logs messages when the extension is enabled and disabled. +Two methods enable you to run custom code: `onActivate()` and `onDeactivate()`. Enabling your extension calls `onActivate()` and disabling your extension calls `onDeactivate()`. You can initiate custom code by implementing `onActivate()`. Implementing `onDeactivate()` gives you the opportunity to clean up after your extension. + +!!! info + Disable extensions from the Lens Extensions page: + + 1. Navigate to **File** > **Extensions** in the top menu bar. (On Mac, it is **Lens** > **Extensions**.) + 2. Click **Disable** on the extension you want to disable. + +The example above logs messages when the extension is enabled and disabled. ### `clusterPages` -Cluster pages appear as part of the cluster dashboard. -They are accessible from the side bar, and are shown in the menu list after *Custom Resources*. -It is conventional to use a cluster page to show information or provide functionality pertaining to the active cluster, along with custom data and functionality your extension may have. -However, it is not limited to the active cluster. -Also, your extension can gain access to the Kubernetes resources in the active cluster in a straightforward manner using the [`clusterStore`](../stores#clusterstore). +Cluster pages appear in the cluster dashboard. Use cluster pages to display information about or add functionality to the active cluster. It is also possible to include custom details from other clusters. Use your extension to access Kubernetes resources in the active cluster with [`clusterStore`](../stores#clusterstore). -The following example adds a cluster page definition to a `LensRendererExtension` subclass: +Add a cluster page definition to a `LensRendererExtension` subclass with the following example: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -56,12 +69,13 @@ export default class ExampleExtension extends LensRendererExtension { } ``` -Cluster pages are objects matching the `PageRegistration` interface. -The `id` field identifies the page, and at its simplest is just a string identifier, as shown in the example above. -The 'id' field can also convey route path details, such as variable parameters provided to a page ([See example below]()). -The `components` field matches the `PageComponents` interface for wich there is one field, `Page`. -`Page` is of type ` React.ComponentType`, which gives you great flexibility in defining the appearance and behaviour of your page. -For the example above `ExamplePage` can be defined in `page.tsx`: +`clusterPages` is an array of objects that satisfy the `PageRegistration` interface. The properties of the `clusterPages` array objects are defined as follows: + +* `id` is a string that identifies the page. +* `components` matches the `PageComponents` interface for which there is one field, `Page`. +* `Page` is of type ` React.ComponentType`. It offers flexibility in defining the appearance and behavior of your page. + +`ExamplePage` in the example above can be defined in `page.tsx`: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -78,14 +92,15 @@ export class ExamplePage extends React.Component<{ extension: LensRendererExtens } ``` -Note that the `ExamplePage` class defines a property named `extension`. -This allows the `ExampleExtension` object to be passed in React-style in the cluster page definition, so that `ExamplePage` can access any `ExampleExtension` subclass data. +Note that the `ExamplePage` class defines the `extension` property. This allows the `ExampleExtension` object to be passed in the cluster page definition in the React style. This way, `ExamplePage` can access all `ExampleExtension` subclass data. + +The above example shows how to create a cluster page, but not how to make that page available to the Lens user. Use `clusterPageMenus`, covered in the next section, to add cluster pages to the Lens UI. ### `clusterPageMenus` -The above example code shows how to create a cluster page but not how to make it available to the Lens user. -Cluster pages are typically made available through a menu item in the cluster dashboard sidebar. -Expanding on the above example a cluster page menu is added to the `ExampleExtension` definition: +`clusterPageMenus` allows you to add cluster page menu items to the secondary left nav. + +By expanding on the above example, you can add a cluster page menu item to the `ExampleExtension` definition: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -114,14 +129,16 @@ export default class ExampleExtension extends LensRendererExtension { } ``` -Cluster page menus are objects matching the `ClusterPageMenuRegistration` interface. -They define the appearance of the cluster page menu item in the cluster dashboard sidebar and the behaviour when the cluster page menu item is activated (typically by a mouse click). -The example above uses the `target` field to set the behaviour as a link to the cluster page with `id` of `"hello"`. -This is done by setting `target`'s `pageId` field to `"hello"`. -The cluster page menu item's appearance is defined by setting the `title` field to the text that is to be displayed in the cluster dashboard sidebar. -The `components` field is used to set an icon that appears to the left of the `title` text in the sidebar. -Thus when the `"Hello World"` menu item is activated the cluster dashboard will show the contents of `ExamplePage`. -This example requires the definition of another React-based component, `ExampleIcon`, which has been added to `page.tsx`: +`clusterPageMenus` is an array of objects that satisfy the `ClusterPageMenuRegistration` interface. This element defines how the cluster page menu item will appear and what it will do when you click it. The properties of the `clusterPageMenus` array objects are defined as follows: + +* `target` links to the relevant cluster page using `pageId`. +* `pageId` takes the value of the relevant cluster page's `id` property. +* `title` sets the name of the cluster page menu item that will appear in the left side menu. +* `components` is used to set an icon that appears to the left of the `title` text in the left side menu. + +The above example creates a menu item that reads **Hello World**. When users click **Hello World**, the cluster dashboard will show the contents of `Example Page`. + +This example requires the definition of another React-based component, `ExampleIcon`, which has been added to `page.tsx`, as follows: ``` typescript import { LensRendererExtension, Component } from "@k8slens/extensions"; @@ -142,14 +159,13 @@ export class ExamplePage extends React.Component<{ extension: LensRendererExtens } ``` -`ExampleIcon` introduces one of Lens's built-in components available to extension developers, the `Component.Icon`. -Built in are the [Material Design](https://material.io) [icons](https://material.io/resources/icons/). -One can be selected by name via the `material` field. -`ExampleIcon` also sets a tooltip, shown when the Lens user hovers over the icon with a mouse, by setting the `tooltip` field. +Lens includes various built-in components available for extension developers to use. One of these is the `Component.Icon`, introduced in `ExampleIcon`, which you can use to access any of the [icons](https://material.io/resources/icons/) available at [Material Design](https://material.io). The properties that `Component.Icon` uses are defined as follows: + +* `material` takes the name of the icon you want to use. +* `tooltip` sets the text you want to appear when a user hovers over the icon. + +`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: -A cluster page menu can also be used to define a foldout submenu in the cluster dashboard sidebar. -This enables the grouping of cluster pages. -The following example shows how to specify a submenu having two menu items: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -200,24 +216,17 @@ export default class ExampleExtension extends LensRendererExtension { } ``` -The above defines two cluster pages and three cluster page menu objects. -The cluster page definitons are straightforward. -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 +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. + +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` -Global pages appear independently of the cluster dashboard and they fill the Lens UI space. -A global page is typically triggered from the cluster menu using a [global page menu](#globalpagemenus). -They can also be triggered by a [custom app menu selection](../main-extension#appmenus) from a Main Extension or a [custom status bar item](#statusbaritems). -Global pages can appear even when there is no active cluster, unlike cluster pages. -It is conventional to use a global page to show information and provide functionality relevant across clusters, along with custom data and functionality that your extension may have. +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. + +Typically, you would use a [global page menu](#globalpagemenus) located in the left nav to trigger a global page. You can also trigger a global page with a [custom app menu selection](../main-extension#appmenus) from a Main Extension or a [custom status bar item](#statusbaritems). Unlike cluster pages, users can trigger global pages even when there is no active cluster. The following example defines a `LensRendererExtension` subclass with a single global page definition: @@ -238,12 +247,13 @@ export default class HelpExtension extends LensRendererExtension { } ``` -Global pages are objects matching the `PageRegistration` interface. -The `id` field identifies the page, and at its simplest is just a string identifier, as shown in the example above. -The 'id' field can also convey route path details, such as variable parameters provided to a page ([See example below]()). -The `components` field matches the `PageComponents` interface for which there is one field, `Page`. -`Page` is of type ` React.ComponentType`, which gives you great flexibility in defining the appearance and behaviour of your page. -For the example above `HelpPage` can be defined in `page.tsx`: +`globalPages` is an array of objects that satisfy the `PageRegistration` interface. The properties of the `globalPages` array objects are defined as follows: + +* `id` is a string that identifies the page. +* `components` matches the `PageComponents` interface for which there is one field, `Page`. +* `Page` is of type `React.ComponentType`. It offers flexibility in defining the appearance and behavior of your page. + +`HelpPage` in the example above can be defined in `page.tsx`: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -260,20 +270,19 @@ export class HelpPage extends React.Component<{ extension: LensRendererExtension } ``` -Note that the `HelpPage` class defines a property named `extension`. -This allows the `HelpExtension` object to be passed in React-style in the global page definition, so that `HelpPage` can access any `HelpExtension` subclass data. +Note that the `HelpPage` class defines the `extension` property. This allows the `HelpExtension` object to be passed in the global page definition in the React-style. This way, `HelpPage` can access all `HelpExtension` subclass data. -This example code shows how to create a global page but not how to make it available to the Lens user. -Global pages are typically made available through a number of ways. -Menu items can be added to the Lens app menu system and set to open a global page when activated (See [`appMenus` in the Main Extension guide](../main-extension#appmenus)). -Interactive elements can be placed on the status bar (the blue strip along the bottom of the Lens UI) and can be configured to link to a global page when activated (See [`statusBarItems`](#statusbaritems)). -As well, global pages can be made accessible from the cluster menu, which is the vertical strip along the left side of the Lens UI showing the available cluster icons, and the Add Cluster icon. -Global page menu icons that are defined using [`globalPageMenus`](#globalpagemenus) appear below the Add Cluster icon. +This example code shows how to create a global page, but not how to make that page available to the Lens user. Global pages can be made available in the following ways: + +* To add global pages to the top menu bar, see [`appMenus`](../main-extension#appmenus) in the Main Extension guide. +* To add global pages as an interactive element in the blue status bar along the bottom of the Lens UI, see [`statusBarItems`](#statusbaritems). +* To add global pages to the left side menu, see [`globalPageMenus`](#globalpagemenus). ### `globalPageMenus` -Global page menus connect a global page to the cluster menu, which is the vertical strip along the left side of the Lens UI showing the available cluster icons, and the Add Cluster icon. -Expanding on the example from [`globalPages`](#globalPages) a global page menu is added to the `HelpExtension` definition: +`globalPageMenus` allows you to add global page menu items to the left nav. + +By expanding on the above example, you can add a global page menu item to the `HelpExtension` definition: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -302,14 +311,16 @@ export default class HelpExtension extends LensRendererExtension { } ``` -Global page menus are objects matching the `PageMenuRegistration` interface. -They define the appearance of the global page menu item in the cluster menu and the behaviour when the global page menu item is activated (typically by a mouse click). -The example above uses the `target` field to set the behaviour as a link to the global page with `id` of `"help"`. -This is done by setting `target`'s `pageId` field to `"help"`. -The global page menu item's appearance is defined by setting the `title` field to the text that is to be displayed as a tooltip in the cluster menu. -The `components` field is used to set an icon that appears in the cluster menu. -Thus when the `"Help"` icon is activated the contents of `ExamplePage` will be shown. -This example requires the definition of another React-based component, `HelpIcon`, which has been added to `page.tsx`: +`globalPageMenus` is an array of objects that satisfy the `PageMenuRegistration` interface. This element defines how the global page menu item will appear and what it will do when you click it. The properties of the `globalPageMenus` array objects are defined as follows: + +* `target` links to the relevant global page using `pageId`. +* `pageId` takes the value of the relevant global page's `id` property. +* `title` sets the name of the global page menu item that will display as a tooltip in the left nav. +* `components` is used to set an icon that appears in the left nav. + +The above example creates a "Help" icon menu item. When users click the icon, the Lens UI will display the contents of `ExamplePage`. + +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 import { LensRendererExtension, Component } from "@k8slens/extensions"; @@ -330,14 +341,21 @@ export class HelpPage extends React.Component<{ extension: LensRendererExtension } ``` -`HelpIcon` introduces one of Lens's built-in components available to extension developers, the `Component.Icon`. -Built in are the [Material Design](https://material.io) [icons](https://material.io/resources/icons/). -One can be selected by name via the `material` field. +Lens includes various built-in components available for extension developers to use. One of these is the `Component.Icon`, introduced in `HelpIcon`, which you can use to access any of the [icons](https://material.io/resources/icons/) available at [Material Design](https://material.io). The property that `Component.Icon` uses is defined as follows: + +* `material` takes the name of the icon you want to use. + +This is what the example will look like, including how the menu item will appear in the left nav: + +![globalPageMenus](images/globalpagemenus.png) ### `clusterFeatures` Cluster features are Kubernetes resources that can be applied to and managed within the active cluster. -They can be installed/uninstalled by the Lens user from the [cluster settings page](). +They can be installed and uninstalled by the Lens user from the cluster **Settings** page. + +!!! info + To access the cluster **Settings** page, right-click the relevant cluster in the left side menu and click **Settings**. The following example shows how to add a cluster feature as part of a `LensRendererExtension`: @@ -364,8 +382,11 @@ export default class ExampleFeatureExtension extends LensRendererExtension { ]; } ``` -The `title` and `components.Description` fields provide content that appears on the cluster settings page, in the **Features** section. -The `feature` field must specify an instance which extends the abstract class `ClusterFeature.Feature`, and specifically implement the following methods: + +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. +* `feature` specifies an instance which extends the abstract class `ClusterFeature.Feature`, and specifically implements the following methods: ``` typescript abstract install(cluster: Cluster): Promise; @@ -374,20 +395,21 @@ The `feature` field must specify an instance which extends the abstract class `C abstract updateStatus(cluster: Cluster): Promise; ``` -The `install()` method is typically called by Lens when a user has indicated that this feature is to be installed (i.e. clicked **Install** for the feature on the cluster settings page). -The implementation of this method should install kubernetes resources using the `applyResources()` method, or by directly accessing the kubernetes api ([`K8sApi`](tbd)). +The four methods listed above are defined as follows: -The `upgrade()` method is typically called by Lens when a user has indicated that this feature is to be upgraded (i.e. clicked **Upgrade** for the feature on the cluster settings page). -The implementation of this method should upgrade the kubernetes resources already installed, if relevant to the feature. +* The `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 `uninstall()` method is typically called by Lens when a user has indicated that this feature is to be uninstalled (i.e. clicked **Uninstall** for the feature on the cluster settings page). -The implementation of this method should uninstall kubernetes resources using the kubernetes api (`K8sApi`) +* The `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 `updateStatus()` method is called periodically by Lens to determine details about the feature's current status. -The implementation of this method should provide the current status information in the `status` field of the `ClusterFeature.Feature` parent class. -The `status.currentVersion` and `status.latestVersion` fields may be displayed by Lens in describing the feature. -The `status.installed` field should be set to true if the feature is currently installed, otherwise false. -Also, Lens relies on the `status.canUpgrade` field to determine if the feature can be upgraded (i.e a new version could be available) so the implementation should set the `status.canUpgrade` field according to specific rules for the feature, if relevant. +* The `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()`: + + * `status.currentVersion` and `status.latestVersion` may be displayed by Lens in the feature's description. + + * `status.installed` should be set to `true` if the feature is installed, and `false` otherwise. + + * `status.canUpgrade` is set according to a rule meant to determine whether the feature can be upgraded. This rule can involve `status.currentVersion` and `status.latestVersion`, if desired. The following shows a very simple implementation of a `ClusterFeature`: @@ -435,9 +457,9 @@ export class ExampleFeature extends ClusterFeature.Feature { } ``` -This example implements the `install()` method by simply invoking the helper `applyResources()` method. +This example implements the `install()` method by invoking the helper `applyResources()` method. `applyResources()` tries to apply all resources read from all files found in the folder path provided. -In this case this folder path is the `../resources` subfolder relative to current source code's folder. +In this case the folder path is the `../resources` subfolder relative to the current source code's folder. The file `../resources/example-pod.yml` could contain: ``` yaml @@ -451,18 +473,18 @@ spec: image: nginx ``` -The `upgrade()` method in the example above is implemented by simply invoking the `install()` method. -Depending on the feature to be supported by an extension, upgrading may require additional and/or different steps. +The example above implements the four methods as follows: -The `uninstall()` method is implemented in the example above by utilizing the [`K8sApi`](tbd) provided by Lens to simply delete the `example-pod` pod applied by the `install()` method. +* It implements `upgrade()` by invoking the `install()` method. Depending on the feature to be supported by an extension, upgrading may require additional and/or different steps. -The `updateStatus()` method is implemented above by using the [`K8sApi`](tbd) as well, this time to get information from the `example-pod` pod, in particular to determine if it is installed, what version is associated with it, and if it can be upgraded. -How the status is updated for a specific cluster feature is up to the implementation. +* It implements `uninstall()` by utilizing the [Kubernetes API](../api/README.md) which Lens provides to delete the `example-pod` applied by the `install()` method. + +* It implements `updateStatus()` by using the [Kubernetes API](../api/README.md) which Lens provides to determine whether the `example-pod` is installed, what version is associated with it, and whether it can be upgraded. The implementation determines what the status is for a specific cluster feature. ### `appPreferences` -The Preferences page is a built-in global page. -Extensions can add custom preferences to the Preferences page, thus providing a single location for users to configure global options, for Lens and extensions alike. +The Lens **Preferences** page is a built-in global page. You can use Lens extensions to add custom preferences to the Preferences page, providing a single location for users to configure global options. + The following example demonstrates adding a custom preference: ``` typescript @@ -487,13 +509,20 @@ export default class ExampleRendererExtension extends LensRendererExtension { } ``` -App preferences are objects matching the `AppPreferenceRegistration` interface. -The `title` field specifies the text to show as the heading on the Preferences page. -The `components` field specifies two `React.Component` objects defining the interface for the preference. -`Input` should specify an interactive input element for your preference and `Hint` should provide descriptive information for the preference, which is shown below the `Input` element. -`ExamplePreferenceInput` expects its React props set to an `ExamplePreferenceProps` instance, which is how `ExampleRendererExtension` handles the state of the preference input. -`ExampleRendererExtension` has the field `preference`, which is provided to `ExamplePreferenceInput` when it is created. -In this example `ExamplePreferenceInput`, `ExamplePreferenceHint`, and `ExamplePreferenceProps` are defined in `./src/example-preference.tsx`: +`appPreferences` is an array of objects that satisfies the `AppPreferenceRegistration` interface. The properties of the `appPreferences` array objects are defined as follows: + +* `title` sets the heading text displayed on the Preferences page. +* `components` specifies two `React.Component` objects that define the interface for the preference. + * `Input` specifies an interactive input element for the preference. + * `Hint` provides descriptive information for the preference, shown below the `Input` element. + +!!! note + Note that the input and the hint can be comprised of more sophisticated elements, according to the needs of the extension. + +`ExamplePreferenceInput` expects its React props to be set to an `ExamplePreferenceProps` instance. This is how `ExampleRendererExtension` handles the state of the preference input. +`ExampleRendererExtension` has a `preference` field, which you will add to `ExamplePreferenceInput`. + +In this example `ExamplePreferenceInput`, `ExamplePreferenceHint`, and `ExamplePreferenceProps` are defined in `./src/example-preference.tsx` as follows: ``` typescript import { Component } from "@k8slens/extensions"; @@ -530,28 +559,26 @@ export class ExamplePreferenceHint extends React.Component { } ``` -`ExamplePreferenceInput` implements a simple checkbox (using Lens's `Component.Checkbox`). -It provides `label` as the text to display next to the checkbox and an `onChange` function, which reacts to the checkbox state change. -The checkbox's `value` is initially set to `preference.enabled`. -`ExamplePreferenceInput` is defined with React props of `ExamplePreferenceProps` type, which is an object with a single field, `enabled`. -This is used to indicate the state of the preference, and is bound to the checkbox state in `onChange`. +`ExamplePreferenceInput` implements a simple checkbox using Lens's `Component.Checkbox` using the following properties: + +* `label` sets the text that displays next to the checkbox. +* `value` is initially set to `preference.enabled`. +* `onChange` is a function that responds when the state of the checkbox changes. + +`ExamplePreferenceInput` is defined with the `ExamplePreferenceProps` React props. This is an object with the single `enabled` property. It is used to indicate the state of the preference, and it is bound to the checkbox state in `onChange`. + `ExamplePreferenceHint` is a simple text span. -Note that the input and the hint could comprise of more sophisticated elements, according to the needs of the extension. -Note that the above example introduces decorators `observable` and `observer` from the [`mobx`](https://mobx.js.org/README.html) and [`mobx-react`](https://github.com/mobxjs/mobx-react#mobx-react) packages. -`mobx` simplifies state management and without it this example would not visually update the checkbox properly when the user activates it. -[Lens uses `mobx` extensively for state management](../working-with-mobx) of its own UI elements and it is recommended that extensions rely on it too. -Alternatively, React's state management can be used, though `mobx` is typically simpler to use. +The above example introduces the decorators `observable` and `observer` from the [`mobx`](https://mobx.js.org/README.html) and [`mobx-react`](https://github.com/mobxjs/mobx-react#mobx-react) packages. `mobx` simplifies state management. Without it, this example would not visually update the checkbox properly when the user activates it. [Lens uses `mobx`](../working-with-mobx) extensively for state management of its own UI elements. We recommend that extensions rely on it, as well. +Alternatively, you can use React's state management, though `mobx` is typically simpler to use. -Also note that an extension's state data can be managed using an `ExtensionStore` object, which conveniently handles persistence and synchronization. -The example above defined a `preference` field in the `ExampleRendererExtension` class definition to hold the extension's state primarily to simplify the code for this guide, but it is recommended to manage your extension's state data using [`ExtensionStore`](../stores#extensionstore) +Note that you can manage an extension's state data using an `ExtensionStore` object, which conveniently handles persistence and synchronization. To simplify this guide, the example above defines a `preference` field in the `ExampleRendererExtension` class definition to hold the extension's state. However, we recommend that you manage your extension's state data using [`ExtensionStore`](../stores#extensionstore). ### `statusBarItems` -The Status bar is the blue strip along the bottom of the Lens UI. -Status bar items are `React.ReactNode` types, which can be used to convey status information, or act as a link to a global page, or even an external page. +The status bar is the blue strip along the bottom of the Lens UI. `statusBarItems` are `React.ReactNode` types. They can be used to display status information, or act as links to global pages as well as external pages. -The following example adds a status bar item definition, as well as a global page definition, to a `LensRendererExtension` subclass, and 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 import { LensRendererExtension } from '@k8slens/extensions'; @@ -584,23 +611,23 @@ export default class HelpExtension extends LensRendererExtension { } ``` -The `item` field of a status bar item specifies the `React.Component` to be shown on the status bar. -By default items are added starting from the right side of the status bar. -Typically, `item` would specify an icon and/or a short string of text, considering the limited space on the status bar. -In the example above the `HelpIcon` from the [`globalPageMenus` guide](#globalpagemenus) is reused. -Also, the `item` provides a link to the global page by setting the `onClick` property to a function that calls the `LensRendererExtension` `navigate()` method. -`navigate()` takes as a parameter the id of the global page, which is shown when `navigate()` is called. +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). +* `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` -An extension can add custom menu items (including actions) for specific Kubernetes resource kinds/apiVersions. -These menu items appear under the `...` for each listed resource in the cluster dashboard, and on the title bar of the details page for a specific resource: +An extension can add custom menu items (`kubeObjectMenuItems`) for specific Kubernetes resource kinds and apiVersions. +`kubeObjectMenuItems` appear under the vertical ellipsis for each listed resource in the cluster dashboard: ![List](images/kubeobjectmenuitem.png) +They also appear on the title bar of the details page for specific resources: + ![Details](images/kubeobjectmenuitemdetail.png) -The following example shows how to add a menu for Namespace resources, and associate an action with it: +The following example shows how to add a `kubeObjectMenuItems` for namespace resources with an associated action: ``` typescript import React from "react" @@ -621,12 +648,13 @@ export default class ExampleExtension extends LensRendererExtension { ``` -Kube object menu items are objects matching the `KubeObjectMenuRegistration` interface. -The `kind` field specifies the kubernetes resource type to apply this menu item to, and the `apiVersion` field specifies the kubernetes api to use in relation to this resource type. -This example adds a menu item for namespaces in the cluster dashboard. -The `components` field defines the menu item's appearance and behaviour. -The `MenuItem` field provides a function that returns a `React.Component` given a set of menu item properties. -In this example a `NamespaceMenuItem` object is returned. +`kubeObjectMenuItems` is an array of objects matching the `KubeObjectMenuRegistration` interface. The example above adds a menu item for namespaces in the cluster dashboard. The properties of the `kubeObjectMenuItems` array objects are defined as follows: + +* `kind` specifies the Kubernetes resource type the menu item will apply to. +* `apiVersion` specifies the Kubernetes API version number to use with the resource type. +* `components` defines the menu item's appearance and behavior. +* `MenuItem` provides a function that returns a `React.Component` given a set of menu item properties. In this example a `NamespaceMenuItem` object is returned. + `NamespaceMenuItem` is defined in `./src/namespace-menu-item.tsx`: ```typescript @@ -661,22 +689,18 @@ export function NamespaceMenuItem(props: Component.KubeObjectMenuProps>` it can access the current namespace object (type `K8sApi.Namespace`) through `this.props.object`. -This object can be queried for many details about the current namespace. -In this example the namespace's name is obtained in `componentDidMount()` using the `K8sApi.Namespace` `getName()` method. -The namespace's name is needed to limit the list of pods to only those in this namespace. -To get the list of pods this example uses the kubernetes pods api, specifically the `K8sApi.podsApi.list()` method. -The `K8sApi.podsApi` is automatically configured for the currently active cluster. +Since `NamespaceDetailsItem` extends `React.Component>`, 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, and ideally getting the pods list should be done before 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 is updated. -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` is rendered 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. Multiple details in a drawer can be placed in `` elements for further separation, if desired. The rest of this example's details are defined in `PodsDetailsList`, found in `./pods-details-list.tsx`: @@ -800,6 +815,9 @@ export class PodsDetailsList extends React.Component { ![DetailsWithPods](images/kubeobjectdetailitemwithpods.png) - For each pod the name, age, and status are obtained using the `K8sApi.Pod` methods. +Obtain the name, age, and status for each pod using the `K8sApi.Pod` methods. Construct the table using the `Component.Table` and related elements. + + +For each pod the name, age, and status are obtained using the `K8sApi.Pod` methods. The table is constructed using the `Component.Table` and related elements. -See [`Component` documentation](url?) for further details. \ No newline at end of file +See [`Component` documentation](https://docs.k8slens.dev/master/extensions/api/modules/_renderer_api_components_/) for further details. \ No newline at end of file diff --git a/docs/extensions/guides/stores.md b/docs/extensions/guides/stores.md index 981a7cda3e..0319ee0aab 100644 --- a/docs/extensions/guides/stores.md +++ b/docs/extensions/guides/stores.md @@ -1,18 +1,16 @@ # Stores -Stores are components that persist and synchronize state data. Lens utilizes a number of stores for maintaining a variety of state information. -A few of these are exposed by the extensions api for use by the extension developer. +Stores are components that persist and synchronize state data. Lens uses a number of stores to maintain various kinds of state information, including: -- The `ClusterStore` manages cluster state data such as cluster details, and which cluster is active. -- The `WorkspaceStore` similarly manages workspace state data, such as workspace name, and which clusters belong to a given workspace. -- The `ExtensionStore` is a store for managing custom extension state data. +* The `ClusterStore` manages cluster state data (such as cluster details), and it tracks which cluster is active. +* The `WorkspaceStore` manages workspace state data (such as the workspace name), and and it tracks which clusters belong to a given workspace. +* The `ExtensionStore` manages custom extension state data. + +This guide focuses on the `ExtensionStore`. ## ExtensionStore -Extension developers can create their own store for managing state data by extending the `ExtensionStore` class. -This guide shows how to create a store for the [`appPreferences` guide example](../renderer-extension#apppreferences), which demonstrates how to add a custom preference to the Preferences page. -The preference is a simple boolean that indicates whether something is enabled or not. -The problem with that example is that the enabled state is not stored anywhere, and reverts to the default the next time Lens is started. +Extension developers can create their own store for managing state data by extending the `ExtensionStore` class. This guide shows how to create a store for the [`appPreferences`](../renderer-extension#apppreferences) guide example, which demonstrates how to add a custom preference to the **Preferences** page. The preference is a simple boolean that indicates whether or not something is enabled. However, in the example, the enabled state is not stored anywhere, and it reverts to the default when Lens is restarted. The following example code creates a store for the `appPreferences` guide example: @@ -53,25 +51,14 @@ export class ExamplePreferencesStore extends Store.ExtensionStore(); ``` -First the extension's data model is defined using a simple type, `ExamplePreferencesModel`, which has a single field, `enabled`, representing the preference's state. -`ExamplePreferencesStore` extends `Store.ExtensionStore`, based on the `ExamplePreferencesModel`. -The field `enabled` is added to the `ExamplePreferencesStore` class to hold the "live" or current state of the preference. -Note the use of the `observer` decorator on the `enabled` field. -As for the [`appPreferences` guide example](../renderer-extension#apppreferences), [`mobx`](https://mobx.js.org/README.html) is used for the UI state management, ensuring the checkbox updates when activated by the user. +First, our example defines the extension's data model using the simple `ExamplePreferencesModel` type. This has a single field, `enabled`, which represents the preference's state. `ExamplePreferencesStore` extends `Store.ExtensionStore`, which is based on the `ExamplePreferencesModel`. The `enabled` field is added to the `ExamplePreferencesStore` class to hold the "live" or current state of the preference. Note the use of the `observable` decorator on the `enabled` field. The [`appPreferences`](../renderer-extension#apppreferences) guide example uses [MobX](https://mobx.js.org/README.html) for the UI state management, ensuring the checkbox updates when it's activated by the user. -Then the constructor and two abstract methods are implemented. -In the constructor, the name of the store (`"example-preferences-store"`), and the default (initial) value for the preference state (`enabled: false`) are specified. -The `fromStore()` method is called by Lens internals when the store is loaded, and gives the extension the opportunity to retrieve the stored state data values based on the defined data model. -Here, the `enabled` field of the `ExamplePreferencesStore` is set to the value from the store whenever `fromStore()` is invoked. -The `toJSON()` method is complementary to `fromStore()`, and is called when the store is being saved. -`toJSON()` must provide a JSON serializable object, facilitating its storage in JSON format. -The `toJS()` function from [`mobx`](https://mobx.js.org/README.html) is convenient for this purpose, and is used here. +Next, our example implements the constructor and two abstract methods. The constructor specifies the name of the store (`"example-preferences-store"`) and the default (initial) value for the preference state (`enabled: false`). Lens internals call the `fromStore()` method when the store loads. It gives the extension the opportunity to retrieve the stored state data values based on the defined data model. The `enabled` field of the `ExamplePreferencesStore` is set to the value from the store whenever `fromStore()` is invoked. The `toJSON()` method is complementary to `fromStore()`. It is called when the store is being saved. +`toJSON()` must provide a JSON serializable object, facilitating its storage in JSON format. The `toJS()` function from [`mobx`](https://mobx.js.org/README.html) is convenient for this purpose, and is used here. -Finally, `examplePreferencesStore` is created by calling `ExamplePreferencesStore.getInstance()`, and exported for use by other parts of the extension. -Note that `examplePreferencesStore` is a singleton, calling this function again will not create a new store. +Finally, `examplePreferencesStore` is created by calling `ExamplePreferencesStore.getInstance()`, and exported for use by other parts of the extension. Note that `examplePreferencesStore` is a singleton. Calling this function again will not create a new store. -The following example code, modified from the [`appPreferences` guide example](../renderer-extension#apppreferences) demonstrates how to use the extension store. -`examplePreferencesStore` must be loaded in the main process, where loaded stores are automatically saved when exiting Lens. This can be done in `./main.ts`: +The following example code, modified from the [`appPreferences`](../renderer-extension#apppreferences) guide demonstrates how to use the extension store. `examplePreferencesStore` must be loaded in the main process, where loaded stores are automatically saved when exiting Lens. This can be done in `./main.ts`: ``` typescript import { LensMainExtension } from "@k8slens/extensions"; @@ -84,8 +71,8 @@ export default class ExampleMainExtension extends LensMainExtension { } ``` -Here, `examplePreferencesStore` is loaded with `examplePreferencesStore.loadExtension(this)`, which is conveniently called from the `onActivate()` method of `ExampleMainExtension`. -Similarly, `examplePreferencesStore` must be loaded in the renderer process where the `appPreferences` are handled. This can be done in `./renderer.ts`: +Here, `examplePreferencesStore` loads with `examplePreferencesStore.loadExtension(this)`, which is conveniently called from the `onActivate()` method of `ExampleMainExtension`. +Similarly, `examplePreferencesStore` must load in the renderer process where the `appPreferences` are handled. This can be done in `./renderer.ts`: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -111,8 +98,7 @@ export default class ExampleRendererExtension extends LensRendererExtension { } ``` -Again, `examplePreferencesStore.loadExtension(this)` is called to load `examplePreferencesStore`, this time from the `onActivate()` method of `ExampleRendererExtension`. -Also, there is no longer the need for the `preference` field in the `ExampleRendererExtension` class, as the props for `ExamplePreferenceInput` is now `examplePreferencesStore`. +Again, `examplePreferencesStore.loadExtension(this)` is called to load `examplePreferencesStore`, this time from the `onActivate()` method of `ExampleRendererExtension`. There is no longer the need for the `preference` field in the `ExampleRendererExtension` class because the props for `ExamplePreferenceInput` is now `examplePreferencesStore`. `ExamplePreferenceInput` is defined in `./src/example-preference.tsx`: ``` typescript @@ -151,5 +137,5 @@ export class ExamplePreferenceHint extends React.Component { ``` The only change here is that `ExamplePreferenceProps` defines its `preference` field as an `ExamplePreferencesStore` type. -Everything else works as before except now the `enabled` state persists across Lens restarts because it is managed by the +Everything else works as before, except that now the `enabled` state persists across Lens restarts because it is managed by the `examplePreferencesStore`. \ No newline at end of file diff --git a/docs/extensions/guides/working-with-mobx.md b/docs/extensions/guides/working-with-mobx.md index 5577ff6bdc..41ddc487a6 100644 --- a/docs/extensions/guides/working-with-mobx.md +++ b/docs/extensions/guides/working-with-mobx.md @@ -1,23 +1,26 @@ -# Working with mobx +# Working with MobX ## Introduction -Lens uses `mobx` as its state manager on top of React's state management system. -This helps with having a more declarative style of managing state, as opposed to `React`'s native `setState` mechanism. -You should already have a basic understanding of how `React` handles state ([read here](https://reactjs.org/docs/faq-state.html) for more information). -However, if you do not, here is a quick overview. +Lens uses MobX on top of React's state management system. +The result is a more declarative state management style, rather than React's native `setState` mechanism. -- A `React.Component` is generic over both `Props` and `State` (with default empty object types). -- `Props` should be considered read-only from the point of view of the component and is the mechanism for passing in "arguments" to a component. -- `State` is a component's internal state and can be read by accessing the parent field `state`. -- `State` **must** be updated using the `setState` parent method which merges the new data with the old state. -- `React` does do some optimizations around re-rendering components after quick successions of `setState` calls. +You can review how React handles state management [here](https://reactjs.org/docs/faq-state.html). -## How mobx works: +The following is a quick overview: -`mobx` is a package that provides an abstraction over `React`'s state management. The three main concepts are: -- `observable`: data stored in the component's `state` -- `action`: a function that modifies any `observable` data -- `computed`: data that is derived from `observable` data but is not actually stored. Think of this as computing `isEmpty` vs an `observable` field called `count`. +* `React.Component` is generic with respect to both `props` and `state` (which default to the empty object type). +* `props` should be considered read-only from the point of view of the component, and it is the mechanism for passing in arguments to a component. +* `state` is a component's internal state, and can be read by accessing the super-class field `state`. +* `state` **must** be updated using the `setState` parent method which merges the new data with the old state. +* React does some optimizations around re-rendering components after quick successions of `setState` calls. -Further reading is available from `mobx`'s [website](https://mobx.js.org/the-gist-of-mobx.html). +## How MobX Works: + +MobX is a package that provides an abstraction over React's state management system. The three main concepts are: + +* `observable` is a marker for data stored in the component's `state`. +* `action` is a function that modifies any `observable` data. +* `computed` is a marker for data that is derived from `observable` data, but that is not actually stored. Think of this as computing `isEmpty` rather than an observable field called `count`. + +Further reading is available on the [MobX website](https://mobx.js.org/the-gist-of-mobx.html). diff --git a/extensions/example-extension/package-lock.json b/extensions/example-extension/package-lock.json index 954ba2e41f..16febd433c 100644 --- a/extensions/example-extension/package-lock.json +++ b/extensions/example-extension/package-lock.json @@ -2796,7 +2796,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", - "dev": true + "dev": true, + "optional": true }, "har-schema": { "version": "2.0.0", @@ -3226,6 +3227,7 @@ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, + "optional": true, "requires": { "is-docker": "^2.0.0" } @@ -4367,6 +4369,7 @@ "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "dev": true, + "optional": true, "requires": { "growly": "^1.3.0", "is-wsl": "^2.2.0", @@ -4381,6 +4384,7 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, + "optional": true, "requires": { "yallist": "^4.0.0" } @@ -4390,6 +4394,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", "dev": true, + "optional": true, "requires": { "lru-cache": "^6.0.0" } @@ -4399,6 +4404,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "optional": true, "requires": { "isexe": "^2.0.0" } @@ -4407,7 +4413,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "optional": true } } }, @@ -5398,7 +5405,8 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", - "dev": true + "dev": true, + "optional": true }, "signal-exit": { "version": "3.0.3", @@ -6275,7 +6283,8 @@ "version": "8.3.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==", - "dev": true + "dev": true, + "optional": true }, "v8-to-istanbul": { "version": "7.0.0", diff --git a/extensions/kube-object-event-status/src/resolver.tsx b/extensions/kube-object-event-status/src/resolver.tsx index 69691c2e79..5e9151288f 100644 --- a/extensions/kube-object-event-status/src/resolver.tsx +++ b/extensions/kube-object-event-status/src/resolver.tsx @@ -56,4 +56,4 @@ export function resolveStatusForCronJobs(cronJob: K8sApi.CronJob): K8sApi.KubeOb text: `${event.message}`, timestamp: event.metadata.creationTimestamp }; -} \ No newline at end of file +} diff --git a/extensions/license-menu-item/package-lock.json b/extensions/license-menu-item/package-lock.json index 071d2f62a6..5d6de53633 100644 --- a/extensions/license-menu-item/package-lock.json +++ b/extensions/license-menu-item/package-lock.json @@ -2868,7 +2868,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", - "dev": true + "dev": true, + "optional": true }, "har-schema": { "version": "2.0.0", @@ -3298,6 +3299,7 @@ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, + "optional": true, "requires": { "is-docker": "^2.0.0" } @@ -4460,6 +4462,7 @@ "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "dev": true, + "optional": true, "requires": { "growly": "^1.3.0", "is-wsl": "^2.2.0", @@ -4474,6 +4477,7 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, + "optional": true, "requires": { "yallist": "^4.0.0" } @@ -4483,6 +4487,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", "dev": true, + "optional": true, "requires": { "lru-cache": "^6.0.0" } @@ -4492,6 +4497,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "optional": true, "requires": { "isexe": "^2.0.0" } @@ -4500,7 +4506,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "optional": true } } }, @@ -5516,7 +5523,8 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", - "dev": true + "dev": true, + "optional": true }, "signal-exit": { "version": "3.0.3", @@ -6406,7 +6414,8 @@ "version": "8.3.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==", - "dev": true + "dev": true, + "optional": true }, "v8-to-istanbul": { "version": "7.0.0", diff --git a/extensions/metrics-cluster-feature/package-lock.json b/extensions/metrics-cluster-feature/package-lock.json index 334135b4eb..ea68169ca9 100644 --- a/extensions/metrics-cluster-feature/package-lock.json +++ b/extensions/metrics-cluster-feature/package-lock.json @@ -2816,7 +2816,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", - "dev": true + "dev": true, + "optional": true }, "har-schema": { "version": "2.0.0", @@ -3246,6 +3247,7 @@ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, + "optional": true, "requires": { "is-docker": "^2.0.0" } @@ -4394,6 +4396,7 @@ "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "dev": true, + "optional": true, "requires": { "growly": "^1.3.0", "is-wsl": "^2.2.0", @@ -4408,6 +4411,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "optional": true, "requires": { "isexe": "^2.0.0" } @@ -5434,7 +5438,8 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", - "dev": true + "dev": true, + "optional": true }, "signal-exit": { "version": "3.0.3", @@ -6311,7 +6316,8 @@ "version": "8.3.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==", - "dev": true + "dev": true, + "optional": true }, "v8-to-istanbul": { "version": "7.0.0", diff --git a/extensions/node-menu/package-lock.json b/extensions/node-menu/package-lock.json index 5e1eec009a..2595e825f7 100644 --- a/extensions/node-menu/package-lock.json +++ b/extensions/node-menu/package-lock.json @@ -2796,7 +2796,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", - "dev": true + "dev": true, + "optional": true }, "har-schema": { "version": "2.0.0", @@ -3226,6 +3227,7 @@ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, + "optional": true, "requires": { "is-docker": "^2.0.0" } @@ -4382,6 +4384,7 @@ "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "dev": true, + "optional": true, "requires": { "growly": "^1.3.0", "is-wsl": "^2.2.0", @@ -4396,6 +4399,7 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, + "optional": true, "requires": { "yallist": "^4.0.0" } @@ -4405,6 +4409,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", "dev": true, + "optional": true, "requires": { "lru-cache": "^6.0.0" } @@ -4414,6 +4419,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "optional": true, "requires": { "isexe": "^2.0.0" } @@ -4422,7 +4428,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "optional": true } } }, @@ -5438,7 +5445,8 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", - "dev": true + "dev": true, + "optional": true }, "signal-exit": { "version": "3.0.3", @@ -6315,7 +6323,8 @@ "version": "8.3.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==", - "dev": true + "dev": true, + "optional": true }, "v8-to-istanbul": { "version": "7.0.0", diff --git a/extensions/pod-menu/package-lock.json b/extensions/pod-menu/package-lock.json index ea98213fb3..4409d2a89d 100644 --- a/extensions/pod-menu/package-lock.json +++ b/extensions/pod-menu/package-lock.json @@ -626,7 +626,644 @@ }, "@k8slens/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": { "version": "1.8.1", @@ -2796,7 +3433,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", - "dev": true + "dev": true, + "optional": true }, "har-schema": { "version": "2.0.0", @@ -3226,6 +3864,7 @@ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, + "optional": true, "requires": { "is-docker": "^2.0.0" } @@ -4382,6 +5021,7 @@ "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "dev": true, + "optional": true, "requires": { "growly": "^1.3.0", "is-wsl": "^2.2.0", @@ -4396,6 +5036,7 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, + "optional": true, "requires": { "yallist": "^4.0.0" } @@ -4405,6 +5046,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", "dev": true, + "optional": true, "requires": { "lru-cache": "^6.0.0" } @@ -4414,6 +5056,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "optional": true, "requires": { "isexe": "^2.0.0" } @@ -4422,7 +5065,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "optional": true } } }, @@ -5438,7 +6082,8 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", - "dev": true + "dev": true, + "optional": true }, "signal-exit": { "version": "3.0.3", @@ -6315,7 +6960,8 @@ "version": "8.3.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==", - "dev": true + "dev": true, + "optional": true }, "v8-to-istanbul": { "version": "7.0.0", diff --git a/extensions/pod-menu/src/logs-menu.tsx b/extensions/pod-menu/src/logs-menu.tsx index 706efcf128..1063207d0c 100644 --- a/extensions/pod-menu/src/logs-menu.tsx +++ b/extensions/pod-menu/src/logs-menu.tsx @@ -9,13 +9,9 @@ export class PodLogsMenu extends React.Component { Navigation.hideDetails(); const pod = this.props.object; - Component.createPodLogsTab({ - pod, - containers: pod.getContainers(), - initContainers: pod.getInitContainers(), + Component.logTabStore.createPodTab({ + selectedPod: pod, selectedContainer: container, - showTimestamps: false, - previous: false, }); } diff --git a/extensions/telemetry/package-lock.json b/extensions/telemetry/package-lock.json index 89e30dc2f4..9829eebb6b 100644 --- a/extensions/telemetry/package-lock.json +++ b/extensions/telemetry/package-lock.json @@ -2901,7 +2901,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", - "dev": true + "dev": true, + "optional": true }, "har-schema": { "version": "2.0.0", @@ -3337,6 +3338,7 @@ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, + "optional": true, "requires": { "is-docker": "^2.0.0" } @@ -4533,6 +4535,7 @@ "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "dev": true, + "optional": true, "requires": { "growly": "^1.3.0", "is-wsl": "^2.2.0", @@ -4547,6 +4550,7 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, + "optional": true, "requires": { "yallist": "^4.0.0" } @@ -4556,6 +4560,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", "dev": true, + "optional": true, "requires": { "lru-cache": "^6.0.0" } @@ -4564,13 +4569,15 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true + "dev": true, + "optional": true }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "optional": true, "requires": { "isexe": "^2.0.0" } @@ -4579,7 +4586,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "optional": true } } }, @@ -5595,7 +5603,8 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", - "dev": true + "dev": true, + "optional": true }, "signal-exit": { "version": "3.0.3", diff --git a/integration/__tests__/app.tests.ts b/integration/__tests__/app.tests.ts index 410712e4f0..ca30015fa1 100644 --- a/integration/__tests__/app.tests.ts +++ b/integration/__tests__/app.tests.ts @@ -5,8 +5,11 @@ cluster and vice versa. */ import { Application } from "spectron"; -import * as util from "../helpers/utils"; +import * as utils from "../helpers/utils"; import { spawnSync } from "child_process"; +import { listHelmRepositories } from "../helpers/utils"; +import { fail } from "assert"; + jest.setTimeout(60000); @@ -16,7 +19,7 @@ describe("Lens integration tests", () => { const BACKSPACE = "\uE003"; let app: Application; const appStart = async () => { - app = util.setup(); + app = utils.setup(); await app.start(); // Wait for splash screen to be closed while (await app.client.getWindowCount() > 1); @@ -71,7 +74,7 @@ describe("Lens integration tests", () => { afterAll(async () => { if (app?.isRunning()) { - await util.tearDown(app); + await utils.tearDown(app); } }); @@ -93,7 +96,13 @@ describe("Lens integration tests", () => { }); it("ensures helm repos", async () => { - await app.client.waitUntilTextExists("div.repos #message-bitnami", "bitnami"); // wait for the helm-cli to fetch the bitnami repo + const repos = await listHelmRepositories(); + + 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.click("#HelmRepoSelect"); // click the repo select to activate the drop-down await app.client.waitUntilTextExists("div.Select__option", ""); // wait for at least one option to appear (any text) }); @@ -105,12 +114,12 @@ describe("Lens integration tests", () => { }); }); - util.describeIf(ready)("workspaces", () => { + utils.describeIf(ready)("workspaces", () => { beforeAll(appStart, 20000); afterAll(async () => { if (app && app.isRunning()) { - return util.tearDown(app); + return utils.tearDown(app); } }); @@ -169,7 +178,7 @@ describe("Lens integration tests", () => { await app.client.waitUntilTextExists("span.link-text", "Cluster"); }; - util.describeIf(ready)("cluster tests", () => { + utils.describeIf(ready)("cluster tests", () => { let clusterAdded = false; const addCluster = async () => { await clickWhatsNew(app); @@ -184,7 +193,7 @@ describe("Lens integration tests", () => { afterAll(async () => { if (app && app.isRunning()) { - return util.tearDown(app); + return utils.tearDown(app); } }); @@ -207,7 +216,7 @@ describe("Lens integration tests", () => { afterAll(async () => { if (app && app.isRunning()) { - return util.tearDown(app); + return utils.tearDown(app); } }); @@ -313,6 +322,12 @@ describe("Lens integration tests", () => { expectedSelector: "h5.title", expectedText: "Resource Quotas" }, + { + name: "Limit Ranges", + href: "limitranges", + expectedSelector: "h5.title", + expectedText: "Limit Ranges" + }, { name: "HPA", href: "hpa", @@ -483,7 +498,7 @@ describe("Lens integration tests", () => { afterEach(async () => { if (app && app.isRunning()) { - return util.tearDown(app); + return utils.tearDown(app); } }); @@ -493,22 +508,38 @@ describe("Lens integration tests", () => { 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(".PodLogs .VirtualList"); - await app.client.waitForVisible(".PodLogControls"); - await app.client.waitForVisible(".PodLogControls .SearchInput"); - await app.client.waitForVisible(".PodLogControls .SearchInput input"); + 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(".PodLogs .list span.active"); + await app.client.waitForVisible(".LogList .list span.active"); // Click through controls - await app.client.click(".PodLogControls .timestamps-icon"); - await app.client.click(".PodLogControls .undo-icon"); + await app.client.click(".LogControls .show-timestamps"); + await app.client.click(".LogControls .show-previous"); }); }); @@ -517,7 +548,7 @@ describe("Lens integration tests", () => { afterEach(async () => { if (app && app.isRunning()) { - return util.tearDown(app); + return utils.tearDown(app); } }); @@ -544,7 +575,10 @@ describe("Lens integration tests", () => { 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(".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"); diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index f445a9ae48..195de2d073 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -1,8 +1,10 @@ import { Application } from "spectron"; +import * as util from "util"; +import { exec } from "child_process"; const AppPaths: Partial> = { "win32": "./dist/win-unpacked/Lens.exe", - "linux": "./dist/linux-unpacked/kontena-lens", + "linux": "./dist/linux-unpacked/lens", "darwin": "./dist/mac/Lens.app/Contents/MacOS/Lens", }; @@ -26,7 +28,12 @@ export function setup(): Application { }); } +type HelmRepository = { + name: string; + url: string; +}; type AsyncPidGetter = () => Promise; +export const promiseExec = util.promisify(exec); export async function tearDown(app: Application) { const pid = await (app.mainProcess.pid as any as AsyncPidGetter)(); @@ -39,3 +46,19 @@ export async function tearDown(app: Application) { console.error(e); } } + +export async function listHelmRepositories(retries = 0): Promise{ + 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 []; +} diff --git a/mkdocs.yml b/mkdocs.yml index ad3e19572e..dd72a677ea 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -34,7 +34,7 @@ nav: - Main Extension: extensions/guides/main-extension.md - Renderer Extension: extensions/guides/renderer-extension.md - Stores: extensions/guides/stores.md - - Working with mobx: extensions/guides/working-with-mobx.md + - Working with MobX: extensions/guides/working-with-mobx.md - Testing and Publishing: - Testing Extensions: extensions/testing-and-publishing/testing.md - Publishing Extensions: extensions/testing-and-publishing/publishing.md @@ -81,6 +81,8 @@ markdown_extensions: - toc: permalink: "#" toc_depth: 3 + - admonition: {} + - pymdownx.details: {} extra: generator: false diff --git a/package.json b/package.json index 7d00909318..735a5fa341 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "kontena-lens", "productName": "Lens", "description": "Lens - The Kubernetes IDE", - "version": "4.1.0-alpha.0", + "version": "4.1.0-alpha.1", "main": "static/build/main.js", "copyright": "© 2020, Mirantis, Inc.", "license": "MIT", @@ -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" }, "config": { - "bundledKubectlVersion": "1.17.15", + "bundledKubectlVersion": "1.18.15", "bundledHelmVersion": "3.4.2" }, "engines": { @@ -103,7 +103,11 @@ ], "linux": { "category": "Network", + "executableName": "lens", + "artifactName": "${productName}-${version}.${arch}.${ext}", "target": [ + "deb", + "rpm", "snap", "AppImage" ], @@ -200,7 +204,7 @@ "jsonpath": "^1.0.2", "lodash": "^4.17.15", "mac-ca": "^1.0.4", - "marked": "^1.1.0", + "marked": "^1.2.7", "md5-file": "^5.0.0", "mobx": "^5.15.7", "mobx-observable-history": "^1.0.3", @@ -219,7 +223,7 @@ "request-promise-native": "^1.0.8", "semver": "^7.3.2", "serializr": "^2.0.3", - "shell-env": "^3.0.0", + "shell-env": "^3.0.1", "spdy": "^4.0.2", "tar": "^6.0.5", "tcp-port-used": "^1.0.1", @@ -244,7 +248,7 @@ "@types/electron-devtools-installer": "^2.2.0", "@types/electron-window-state": "^2.0.34", "@types/fs-extra": "^9.0.1", - "@types/hapi": "^18.0.3", + "@types/hapi": "^18.0.5", "@types/hoist-non-react-statics": "^3.3.1", "@types/html-webpack-plugin": "^3.2.3", "@types/http-proxy": "^1.17.4", @@ -313,7 +317,7 @@ "jest-canvas-mock": "^2.3.0", "jest-fetch-mock": "^3.0.3", "jest-mock-extended": "^1.0.10", - "make-plural": "^6.2.1", + "make-plural": "^6.2.2", "mini-css-extract-plugin": "^0.9.0", "moment": "^2.26.0", "node-loader": "^0.6.0", @@ -328,6 +332,7 @@ "react-refresh": "^0.9.0", "react-router-dom": "^5.2.0", "react-select": "^3.1.0", + "react-select-event": "^5.1.0", "react-window": "^1.8.5", "sass-loader": "^8.0.2", "sharp": "^0.26.1", diff --git a/src/common/__tests__/search-store.test.ts b/src/common/__tests__/search-store.test.ts index 7939ef1d8c..d361c858fd 100644 --- a/src/common/__tests__/search-store.test.ts +++ b/src/common/__tests__/search-store.test.ts @@ -77,4 +77,4 @@ describe("search store tests", () => { searchStore.onSearch(logs, "Starting"); expect(searchStore.totalFinds).toBe(2); }); -}); \ No newline at end of file +}); diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index 08ca359ce5..b74941a790 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -101,4 +101,4 @@ describe("user store tests", () => { expect(us.lastSeenAppVersion).toBe("0.0.0"); }); }); -}); \ No newline at end of file +}); diff --git a/src/common/__tests__/workspace-store.test.ts b/src/common/__tests__/workspace-store.test.ts index e69ebda0aa..355eb8b2ce 100644 --- a/src/common/__tests__/workspace-store.test.ts +++ b/src/common/__tests__/workspace-store.test.ts @@ -36,6 +36,13 @@ describe("workspace store tests", () => { expect(ws.getById(WorkspaceStore.defaultId)).not.toBe(null); }); + it("default workspace should be enabled", () => { + const ws = WorkspaceStore.getInstance(); + + expect(ws.workspaces.size).toBe(1); + expect(ws.getById(WorkspaceStore.defaultId).enabled).toBe(true); + }); + it("cannot remove the default workspace", () => { const ws = WorkspaceStore.getInstance(); diff --git a/src/common/custom-errors.ts b/src/common/custom-errors.ts index 9bcf3a998a..177ef7578f 100644 --- a/src/common/custom-errors.ts +++ b/src/common/custom-errors.ts @@ -10,4 +10,4 @@ export class ExecValidationNotFoundError extends Error { this.name = this.constructor.name; Error.captureStackTrace(this, this.constructor); } -} \ No newline at end of file +} diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index bb0e6b86d2..02a9faef92 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -175,4 +175,4 @@ export function validateKubeConfig (config: KubeConfig) { throw new ExecValidationNotFoundError(execCommand, isAbsolute); } } -} \ No newline at end of file +} diff --git a/src/common/prometheus-providers.ts b/src/common/prometheus-providers.ts index a5c515b338..5496163c38 100644 --- a/src/common/prometheus-providers.ts +++ b/src/common/prometheus-providers.ts @@ -10,4 +10,4 @@ import { PrometheusProviderRegistry } from "../main/prometheus/provider-registry PrometheusProviderRegistry.registerProvider(provider.id, provider); }); -export const prometheusProviders = PrometheusProviderRegistry.getProviders(); \ No newline at end of file +export const prometheusProviders = PrometheusProviderRegistry.getProviders(); diff --git a/src/common/rbac.ts b/src/common/rbac.ts index bd003e87a1..de242b114a 100644 --- a/src/common/rbac.ts +++ b/src/common/rbac.ts @@ -1,42 +1,44 @@ import { getHostedCluster } from "./cluster-store"; export type KubeResource = - "namespaces" | "nodes" | "events" | "resourcequotas" | "services" | + "namespaces" | "nodes" | "events" | "resourcequotas" | "services" | "limitranges" | "secrets" | "configmaps" | "ingresses" | "networkpolicies" | "persistentvolumeclaims" | "persistentvolumes" | "storageclasses" | "pods" | "daemonsets" | "deployments" | "statefulsets" | "replicasets" | "jobs" | "cronjobs" | "endpoints" | "customresourcedefinitions" | "horizontalpodautoscalers" | "podsecuritypolicies" | "poddisruptionbudgets"; 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 } // TODO: auto-populate all resources dynamically (see: kubectl api-resources -o=wide -v=7) export const apiResources: KubeApiResource[] = [ - { resource: "configmaps" }, - { resource: "cronjobs", group: "batch" }, - { resource: "customresourcedefinitions", group: "apiextensions.k8s.io" }, - { resource: "daemonsets", group: "apps" }, - { resource: "deployments", group: "apps" }, - { resource: "endpoints" }, - { resource: "events" }, - { resource: "horizontalpodautoscalers" }, - { resource: "ingresses", group: "networking.k8s.io" }, - { resource: "jobs", group: "batch" }, - { resource: "namespaces" }, - { resource: "networkpolicies", group: "networking.k8s.io" }, - { resource: "nodes" }, - { resource: "persistentvolumes" }, - { resource: "persistentvolumeclaims" }, - { resource: "pods" }, - { resource: "poddisruptionbudgets" }, - { resource: "podsecuritypolicies" }, - { resource: "resourcequotas" }, - { resource: "replicasets", group: "apps" }, - { resource: "secrets" }, - { resource: "services" }, - { resource: "statefulsets", group: "apps" }, - { resource: "storageclasses", group: "storage.k8s.io" }, + { kind: "ConfigMap", apiName: "configmaps" }, + { kind: "CronJob", apiName: "cronjobs", group: "batch" }, + { kind: "CustomResourceDefinition", apiName: "customresourcedefinitions", group: "apiextensions.k8s.io" }, + { kind: "DaemonSet", apiName: "daemonsets", group: "apps" }, + { kind: "Deployment", apiName: "deployments", group: "apps" }, + { kind: "Endpoint", apiName: "endpoints" }, + { kind: "Event", apiName: "events" }, + { kind: "HorizontalPodAutoscaler", apiName: "horizontalpodautoscalers" }, + { kind: "Ingress", apiName: "ingresses", group: "networking.k8s.io" }, + { kind: "Job", apiName: "jobs", group: "batch" }, + { kind: "Namespace", apiName: "namespaces" }, + { kind: "LimitRange", apiName: "limitranges" }, + { kind: "NetworkPolicy", apiName: "networkpolicies", group: "networking.k8s.io" }, + { kind: "Node", apiName: "nodes" }, + { kind: "PersistentVolume", apiName: "persistentvolumes" }, + { kind: "PersistentVolumeClaim", apiName: "persistentvolumeclaims" }, + { kind: "Pod", apiName: "pods" }, + { kind: "PodDisruptionBudget", apiName: "poddisruptionbudgets" }, + { kind: "PodSecurityPolicy", apiName: "podsecuritypolicies" }, + { kind: "ResourceQuota", apiName: "resourcequotas" }, + { kind: "ReplicaSet", apiName: "replicasets", group: "apps" }, + { kind: "Secret", apiName: "secrets" }, + { kind: "Service", apiName: "services" }, + { kind: "StatefulSet", apiName: "statefulsets", group: "apps" }, + { kind: "StorageClass", apiName: "storageclasses", group: "storage.k8s.io" }, ]; export function isAllowedResource(resources: KubeResource | KubeResource[]) { diff --git a/src/common/search-store.ts b/src/common/search-store.ts index a3aba9dcbe..86a6054af3 100644 --- a/src/common/search-store.ts +++ b/src/common/search-store.ts @@ -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"; 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 activeOverlayIndex = -1; // Index withing the occurences array. Showing where is activeOverlay currently located + constructor() { + reaction(() => dockStore.selectedTabId, () => { + searchStore.reset(); + }); + } + /** * Sets default activeOverlayIndex * @param text An array of any textual data (logs, for example) @@ -133,4 +140,4 @@ export class SearchStore { } } -export const searchStore = new SearchStore; \ No newline at end of file +export const searchStore = new SearchStore; diff --git a/src/common/user-store.ts b/src/common/user-store.ts index 1e9f7646f3..b0294d9e5a 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -28,6 +28,7 @@ export interface UserPreferences { downloadBinariesPath?: string; kubectlBinariesPath?: string; openAtLogin?: boolean; + hiddenTableColumns?: Record } export class UserStore extends BaseStore { @@ -54,6 +55,7 @@ export class UserStore extends BaseStore { downloadMirror: "default", downloadKubectlBinaries: true, // Download kubectl binaries matching cluster version openAtLogin: false, + hiddenTableColumns: {}, }; protected async handleOnLoad() { @@ -82,6 +84,15 @@ export class UserStore extends BaseStore { return semver.gt(getAppVersion(), this.lastSeenAppVersion); } + @action + setHiddenTableColumns(tableId: string, names: Set | string[]) { + this.preferences.hiddenTableColumns[tableId] = Array.from(names); + } + + getHiddenTableColumns(tableId: string): Set { + return new Set(this.preferences.hiddenTableColumns[tableId]); + } + @action resetKubeConfigPath() { this.kubeConfigPath = kubeConfigDefaultPath; diff --git a/src/common/utils/__tests__/splitArray.test.ts b/src/common/utils/__tests__/splitArray.test.ts index a401e07701..1e1589fee2 100644 --- a/src/common/utils/__tests__/splitArray.test.ts +++ b/src/common/utils/__tests__/splitArray.test.ts @@ -28,4 +28,4 @@ describe("split array on element tests", () => { test("ten elements, in end array", () => { expect(splitArray([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 9)).toStrictEqual([[0, 1, 2, 3, 4, 5, 6, 7, 8], [], true]); }); -}); \ No newline at end of file +}); diff --git a/src/common/utils/delay.ts b/src/common/utils/delay.ts new file mode 100644 index 0000000000..208e042759 --- /dev/null +++ b/src/common/utils/delay.ts @@ -0,0 +1,6 @@ +// Create async delay for provided timeout in milliseconds + +export async function delay(timeoutMs = 1000) { + if (!timeoutMs) return; + await new Promise(resolve => setTimeout(resolve, timeoutMs)); +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 582135d7f0..942c675f0a 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -7,6 +7,7 @@ export * from "./autobind"; export * from "./base64"; export * from "./camelCase"; export * from "./cloneJson"; +export * from "./delay"; export * from "./debouncePromise"; export * from "./defineGlobal"; export * from "./getRandId"; diff --git a/src/common/utils/singleton.ts b/src/common/utils/singleton.ts index 61269d10b1..caa5471072 100644 --- a/src/common/utils/singleton.ts +++ b/src/common/utils/singleton.ts @@ -26,4 +26,4 @@ class Singleton { } export { Singleton }; -export default Singleton; \ No newline at end of file +export default Singleton; diff --git a/src/common/workspace-store.ts b/src/common/workspace-store.ts index 7688516af2..921a9126a9 100644 --- a/src/common/workspace-store.ts +++ b/src/common/workspace-store.ts @@ -58,14 +58,7 @@ export class Workspace implements WorkspaceModel, WorkspaceState { * @observable */ @observable ownerRef?: string; - /** - * Is workspace enabled - * - * Workspaces that don't have ownerRef will be enabled by default. Workspaces with ownerRef need to explicitly enable a workspace. - * - * @observable - */ - @observable enabled: boolean; + /** * Last active cluster id * @@ -73,6 +66,9 @@ export class Workspace implements WorkspaceModel, WorkspaceState { */ @observable lastActiveClusterId?: ClusterId; + + @observable private _enabled: boolean; + constructor(data: WorkspaceModel) { Object.assign(this, data); @@ -83,6 +79,21 @@ export class Workspace implements WorkspaceModel, WorkspaceState { } } + /** + * Is workspace enabled + * + * Workspaces that don't have ownerRef will be enabled by default. Workspaces with ownerRef need to explicitly enable a workspace. + * + * @observable + */ + get enabled(): boolean { + return !this.isManaged || this._enabled; + } + + set enabled(enabled: boolean) { + this._enabled = enabled; + } + /** * Is workspace managed by an extension */ @@ -134,10 +145,18 @@ export class WorkspaceStore extends BaseStore { static readonly defaultId: WorkspaceId = "default"; private static stateRequestChannel = "workspace:states"; + @observable currentWorkspaceId = WorkspaceStore.defaultId; + @observable workspaces = observable.map(); + private constructor() { super({ configName: "lens-workspace-store", }); + + this.workspaces.set(WorkspaceStore.defaultId, new Workspace({ + id: WorkspaceStore.defaultId, + name: "default" + })); } async load() { @@ -186,15 +205,6 @@ export class WorkspaceStore extends BaseStore { ipcRenderer.removeAllListeners("workspace:state"); } - @observable currentWorkspaceId = WorkspaceStore.defaultId; - - @observable workspaces = observable.map({ - [WorkspaceStore.defaultId]: new Workspace({ - id: WorkspaceStore.defaultId, - name: "default" - }) - }); - @computed get currentWorkspace(): Workspace { return this.getById(this.currentWorkspaceId); } diff --git a/src/extensions/interfaces/index.ts b/src/extensions/interfaces/index.ts index c91d8cdd19..7b1c601537 100644 --- a/src/extensions/interfaces/index.ts +++ b/src/extensions/interfaces/index.ts @@ -1 +1 @@ -export * from "./registrations"; \ No newline at end of file +export * from "./registrations"; diff --git a/src/extensions/interfaces/registrations.ts b/src/extensions/interfaces/registrations.ts index 47c63062ea..ff51d9a824 100644 --- a/src/extensions/interfaces/registrations.ts +++ b/src/extensions/interfaces/registrations.ts @@ -5,4 +5,4 @@ export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../re export type { KubeObjectStatusRegistration } from "../registries/kube-object-status-registry"; export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../registries/page-registry"; export type { PageMenuRegistration, ClusterPageMenuRegistration, PageMenuComponents } from "../registries/page-menu-registry"; -export type { StatusBarRegistration } from "../registries/status-bar-registry"; \ No newline at end of file +export type { StatusBarRegistration } from "../registries/status-bar-registry"; diff --git a/src/extensions/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index 68dd5d6510..49c747da3a 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -38,4 +38,4 @@ export * from "../../renderer/components/+events/kube-event-details"; // specific exports export * from "../../renderer/components/status-brick"; export { terminalStore, createTerminalTab } from "../../renderer/components/dock/terminal.store"; -export { createPodLogsTab } from "../../renderer/components/dock/pod-logs.store"; +export { logTabStore } from "../../renderer/components/dock/log-tab.store"; diff --git a/src/extensions/renderer-api/k8s-api.ts b/src/extensions/renderer-api/k8s-api.ts index fe04550fb7..071d8365ab 100644 --- a/src/extensions/renderer-api/k8s-api.ts +++ b/src/extensions/renderer-api/k8s-api.ts @@ -14,6 +14,7 @@ export { ConfigMap, configMapApi } from "../../renderer/api/endpoints"; export { Secret, secretsApi, ISecretRef } from "../../renderer/api/endpoints"; export { ReplicaSet, replicaSetApi } from "../../renderer/api/endpoints"; export { ResourceQuota, resourceQuotaApi } from "../../renderer/api/endpoints"; +export { LimitRange, limitRangeApi } from "../../renderer/api/endpoints"; export { HorizontalPodAutoscaler, hpaApi } from "../../renderer/api/endpoints"; export { PodDisruptionBudget, pdbApi } from "../../renderer/api/endpoints"; export { Service, serviceApi } from "../../renderer/api/endpoints"; @@ -46,6 +47,7 @@ export type { ConfigMapsStore } from "../../renderer/components/+config-maps/con export type { SecretsStore } from "../../renderer/components/+config-secrets/secrets.store"; export type { ReplicaSetStore } from "../../renderer/components/+workloads-replicasets/replicasets.store"; export type { ResourceQuotasStore } from "../../renderer/components/+config-resource-quotas/resource-quotas.store"; +export type { LimitRangesStore } from "../../renderer/components/+config-limit-ranges/limit-ranges.store"; export type { HPAStore } from "../../renderer/components/+config-autoscalers/hpa.store"; export type { PodDisruptionBudgetsStore } from "../../renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.store"; export type { ServiceStore } from "../../renderer/components/+network-services/services.store"; diff --git a/src/extensions/renderer-api/kube-object-status.ts b/src/extensions/renderer-api/kube-object-status.ts index f609d736fe..616ead1bb2 100644 --- a/src/extensions/renderer-api/kube-object-status.ts +++ b/src/extensions/renderer-api/kube-object-status.ts @@ -8,4 +8,4 @@ export enum KubeObjectStatusLevel { INFO = 1, WARNING = 2, CRITICAL = 3 -} \ No newline at end of file +} diff --git a/src/extensions/renderer-api/theming.ts b/src/extensions/renderer-api/theming.ts index f819036803..b3da69bdbc 100644 --- a/src/extensions/renderer-api/theming.ts +++ b/src/extensions/renderer-api/theming.ts @@ -2,4 +2,4 @@ import { themeStore } from "../../renderer/theme.store"; export function getActiveTheme() { return themeStore.activeTheme; -} \ No newline at end of file +} diff --git a/src/main/cluster-detectors/base-cluster-detector.ts b/src/main/cluster-detectors/base-cluster-detector.ts index 9d52e1a70e..885f96c33e 100644 --- a/src/main/cluster-detectors/base-cluster-detector.ts +++ b/src/main/cluster-detectors/base-cluster-detector.ts @@ -31,4 +31,4 @@ export class BaseClusterDetector { }, }); } -} \ No newline at end of file +} diff --git a/src/main/cluster-detectors/cluster-id-detector.ts b/src/main/cluster-detectors/cluster-id-detector.ts index 2e0cc694ff..810955afae 100644 --- a/src/main/cluster-detectors/cluster-id-detector.ts +++ b/src/main/cluster-detectors/cluster-id-detector.ts @@ -23,4 +23,4 @@ export class ClusterIdDetector extends BaseClusterDetector { return response.metadata.uid; } -} \ No newline at end of file +} diff --git a/src/main/cluster-detectors/detector-registry.ts b/src/main/cluster-detectors/detector-registry.ts index 43c56153c9..b1d1b73447 100644 --- a/src/main/cluster-detectors/detector-registry.ts +++ b/src/main/cluster-detectors/detector-registry.ts @@ -48,4 +48,4 @@ detectorRegistry.add(ClusterIdDetector); detectorRegistry.add(LastSeenDetector); detectorRegistry.add(VersionDetector); detectorRegistry.add(DistributionDetector); -detectorRegistry.add(NodesCountDetector); \ No newline at end of file +detectorRegistry.add(NodesCountDetector); diff --git a/src/main/cluster-detectors/last-seen-detector.ts b/src/main/cluster-detectors/last-seen-detector.ts index e648d5f2f9..0a9bcf9f74 100644 --- a/src/main/cluster-detectors/last-seen-detector.ts +++ b/src/main/cluster-detectors/last-seen-detector.ts @@ -11,4 +11,4 @@ export class LastSeenDetector extends BaseClusterDetector { return { value: new Date().toJSON(), accuracy: 100 }; } -} \ No newline at end of file +} diff --git a/src/main/cluster-detectors/nodes-count-detector.ts b/src/main/cluster-detectors/nodes-count-detector.ts index 0ece5dd080..45584df5bd 100644 --- a/src/main/cluster-detectors/nodes-count-detector.ts +++ b/src/main/cluster-detectors/nodes-count-detector.ts @@ -16,4 +16,4 @@ export class NodesCountDetector extends BaseClusterDetector { return response.items.length; } -} \ No newline at end of file +} diff --git a/src/main/cluster-detectors/version-detector.ts b/src/main/cluster-detectors/version-detector.ts index 8080ef57a1..b19979db8a 100644 --- a/src/main/cluster-detectors/version-detector.ts +++ b/src/main/cluster-detectors/version-detector.ts @@ -16,4 +16,4 @@ export class VersionDetector extends BaseClusterDetector { return response.gitVersion; } -} \ No newline at end of file +} diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 5717c7278d..1b468e3bb6 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -14,7 +14,7 @@ export class ClusterManager extends Singleton { // auto-init clusters autorun(() => { clusterStore.enabledClustersList.forEach(cluster => { - if (!cluster.initialized) { + if (!cluster.initialized && !cluster.initializing) { logger.info(`[CLUSTER-MANAGER]: init cluster`, cluster.getMeta()); cluster.init(port); } diff --git a/src/main/cluster.ts b/src/main/cluster.ts index b9ff62e8ac..956164e10c 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -84,6 +84,14 @@ export class Cluster implements ClusterModel, ClusterState { whenInitialized = when(() => this.initialized); whenReady = when(() => this.ready); + /** + * Is cluster object initializinng on-going + * + * @observable + */ + @observable initializing = false; + + /** * Is cluster object initialized * @@ -182,7 +190,7 @@ export class Cluster implements ClusterModel, ClusterState { */ @observable metadata: ClusterMetadata = {}; /** - * List of allowed namespaces + * List of allowed namespaces verified via K8S::SelfSubjectAccessReview api * * @observable */ @@ -195,7 +203,7 @@ export class Cluster implements ClusterModel, ClusterState { */ @observable allowedResources: string[] = []; /** - * List of accessible namespaces + * List of accessible namespaces provided by user in the Cluster Settings * * @observable */ @@ -216,7 +224,7 @@ export class Cluster implements ClusterModel, ClusterState { * @computed */ @computed get name() { - return this.preferences.clusterName || this.contextName; + return this.preferences.clusterName || this.contextName; } /** @@ -271,8 +279,10 @@ export class Cluster implements ClusterModel, ClusterState { * @param port port where internal auth proxy is listening * @internal */ - @action async init(port: number) { + @action + async init(port: number) { try { + this.initializing = true; this.contextHandler = new ContextHandler(this); this.kubeconfigManager = await KubeconfigManager.create(this, this.contextHandler, port); this.kubeProxyUrl = `http://localhost:${port}${apiKubePrefix}`; @@ -287,6 +297,8 @@ export class Cluster implements ClusterModel, ClusterState { id: this.id, error: err, }); + } finally { + this.initializing = false; } } @@ -323,7 +335,8 @@ export class Cluster implements ClusterModel, ClusterState { * @param force force activation * @internal */ - @action async activate(force = false) { + @action + async activate(force = false) { if (this.activated && !force) { return this.pushState(); } @@ -362,7 +375,8 @@ export class Cluster implements ClusterModel, ClusterState { /** * @internal */ - @action async reconnect() { + @action + async reconnect() { logger.info(`[CLUSTER]: reconnect`, this.getMeta()); this.contextHandler?.stopServer(); await this.contextHandler?.ensureServer(); @@ -389,7 +403,8 @@ export class Cluster implements ClusterModel, ClusterState { * @internal * @param opts refresh options */ - @action async refresh(opts: ClusterRefreshOptions = {}) { + @action + async refresh(opts: ClusterRefreshOptions = {}) { logger.info(`[CLUSTER]: refresh`, this.getMeta()); await this.whenInitialized; await this.refreshConnectionStatus(); @@ -409,7 +424,8 @@ export class Cluster implements ClusterModel, ClusterState { /** * @internal */ - @action async refreshMetadata() { + @action + async refreshMetadata() { logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta()); const metadata = await detectorRegistry.detectForCluster(this); const existingMetadata = this.metadata; @@ -420,7 +436,8 @@ export class Cluster implements ClusterModel, ClusterState { /** * @internal */ - @action async refreshConnectionStatus() { + @action + async refreshConnectionStatus() { const connectionStatus = await this.getConnectionStatus(); this.online = connectionStatus > ClusterStatus.Offline; @@ -430,7 +447,8 @@ export class Cluster implements ClusterModel, ClusterState { /** * @internal */ - @action async refreshAllowedResources() { + @action + async refreshAllowedResources() { this.allowedNamespaces = await this.getAllowedNamespaces(); this.allowedResources = await this.getAllowedResources(); } @@ -657,7 +675,7 @@ export class Cluster implements ClusterModel, ClusterState { for (const namespace of this.allowedNamespaces.slice(0, 10)) { if (!this.resourceAccessStatuses.get(apiResource)) { const result = await this.canI({ - resource: apiResource.resource, + resource: apiResource.apiName, group: apiResource.group, verb: "list", namespace @@ -672,9 +690,19 @@ export class Cluster implements ClusterModel, ClusterState { return apiResources .filter((resource) => this.resourceAccessStatuses.get(resource)) - .map(apiResource => apiResource.resource); + .map(apiResource => apiResource.apiName); } catch (error) { 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 + } } diff --git a/src/main/kubectl.ts b/src/main/kubectl.ts index ebfd2a6a98..7e0d6ed5c7 100644 --- a/src/main/kubectl.ts +++ b/src/main/kubectl.ts @@ -23,10 +23,10 @@ const kubectlMap: Map = new Map([ ["1.14", "1.14.10"], ["1.15", "1.15.11"], ["1.16", "1.16.15"], - ["1.17", bundledVersion], - ["1.18", "1.18.14"], - ["1.19", "1.19.5"], - ["1.20", "1.20.0"] + ["1.17", "1.17.17"], + ["1.18", bundledVersion], + ["1.19", "1.19.7"], + ["1.20", "1.20.2"] ]); const packageMirrors: Map = new Map([ ["default", "https://storage.googleapis.com/kubernetes-release/release"], diff --git a/src/main/router.ts b/src/main/router.ts index b5dd8b99ac..875bd319b5 100644 --- a/src/main/router.ts +++ b/src/main/router.ts @@ -147,7 +147,7 @@ export class Router { 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)); + this.router.add({ method: "post", path: `${apiPrefix}/watch` }, watchRoute.routeWatch.bind(watchRoute)); // Metrics API this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute)); diff --git a/src/main/routes/watch-route.ts b/src/main/routes/watch-route.ts index eb9f007eae..2c86314908 100644 --- a/src/main/routes/watch-route.ts +++ b/src/main/routes/watch-route.ts @@ -1,10 +1,29 @@ +import type { KubeJsonApiData, KubeJsonApiError } from "../../renderer/api/kube-json-api"; + +import plimit from "p-limit"; +import { delay } from "../../common/utils"; import { LensApiRequest } from "../router"; import { LensApi } from "../lens-api"; -import { Watch, KubeConfig } from "@kubernetes/client-node"; +import { KubeConfig, Watch } from "@kubernetes/client-node"; import { ServerResponse } from "http"; import { Request } from "request"; import logger from "../logger"; +export interface IKubeWatchEvent { + type: "ADDED" | "MODIFIED" | "DELETED" | "ERROR" | "STREAM_END"; + object?: T; +} + +export interface IKubeWatchEventStreamEnd extends IKubeWatchEvent { + type: "STREAM_END"; + url: string; + status: number; +} + +export interface IWatchRoutePayload { + apis: string[]; // kube-api url list for subscribing to watch events +} + class ApiWatcher { private apiUrl: string; private response: ServerResponse; @@ -24,6 +43,7 @@ class ApiWatcher { clearInterval(this.processor); } this.processor = setInterval(() => { + if (this.response.finished) return; const events = this.eventBuffer.splice(0); events.map(event => this.sendEvent(event)); @@ -33,7 +53,9 @@ class ApiWatcher { } public stop() { - if (!this.watchRequest) { return; } + if (!this.watchRequest) { + return; + } if (this.processor) { clearInterval(this.processor); @@ -42,11 +64,14 @@ class ApiWatcher { try { this.watchRequest.abort(); - this.sendEvent({ + + const event: IKubeWatchEventStreamEnd = { type: "STREAM_END", url: this.apiUrl, status: 410, - }); + }; + + this.sendEvent(event); logger.debug("watch aborted"); } catch (error) { logger.error(`Watch abort errored:${error}`); @@ -65,50 +90,72 @@ class ApiWatcher { this.watchRequest.abort(); } - private sendEvent(evt: any) { - // convert to "text/event-stream" format - this.response.write(`data: ${JSON.stringify(evt)}\n\n`); + private sendEvent(evt: IKubeWatchEvent) { + this.response.write(`${JSON.stringify(evt)}\n`); } } class WatchRoute extends LensApi { + private response: ServerResponse; - public async routeWatch(request: LensApiRequest) { - const { response, cluster} = request; - const apis: string[] = request.query.getAll("api"); - const watchers: ApiWatcher[] = []; + private setResponse(response: ServerResponse) { + // clean up previous connection and stop all corresponding watch-api requests + // otherwise it happens only by request timeout or something else.. + this.response?.destroy(); + this.response = response; + } - if (!apis.length) { + public async routeWatch(request: LensApiRequest) { + const { response, cluster, payload: { apis } = {} } = request; + + if (!apis?.length) { this.respondJson(response, { - message: "Empty request. Query params 'api' are not provided.", - example: "?api=/api/v1/pods&api=/api/v1/nodes", + message: "watch apis list is empty" }, 400); return; } - response.setHeader("Content-Type", "text/event-stream"); + this.setResponse(response); + response.setHeader("Content-Type", "application/json"); response.setHeader("Cache-Control", "no-cache"); response.setHeader("Connection", "keep-alive"); logger.debug(`watch using kubeconfig:${JSON.stringify(cluster.getProxyKubeconfig(), null, 2)}`); + // limit concurrent k8s requests to avoid possible ECONNRESET-error + const requests = plimit(5); + const watchers = new Map(); + let isWatchRequestEnded = false; + apis.forEach(apiUrl => { const watcher = new ApiWatcher(apiUrl, cluster.getProxyKubeconfig(), response); - watcher.start(); - watchers.push(watcher); + watchers.set(apiUrl, watcher); + + requests(async () => { + if (isWatchRequestEnded) return; + await watcher.start(); + await delay(100); + }); + }); + + function onRequestEnd() { + if (isWatchRequestEnded) return; + isWatchRequestEnded = true; + requests.clearQueue(); + watchers.forEach(watcher => watcher.stop()); + watchers.clear(); + } + + request.raw.req.on("end", () => { + logger.info("Watch request end"); + onRequestEnd(); }); request.raw.req.on("close", () => { - logger.debug("Watch request closed"); - watchers.map(watcher => watcher.stop()); + logger.info("Watch request close"); + onRequestEnd(); }); - - request.raw.req.on("end", () => { - logger.debug("Watch request ended"); - watchers.map(watcher => watcher.stop()); - }); - } } diff --git a/src/migrations/cluster-store/index.ts b/src/migrations/cluster-store/index.ts index c546fdaeda..4a71d4f7ad 100644 --- a/src/migrations/cluster-store/index.ts +++ b/src/migrations/cluster-store/index.ts @@ -18,4 +18,4 @@ export default { ...version270Beta1, ...version360Beta1, ...snap -}; \ No newline at end of file +}; diff --git a/src/renderer/api/__tests__/kube-api.test.ts b/src/renderer/api/__tests__/kube-api.test.ts index 41078e77a3..7481bd096a 100644 --- a/src/renderer/api/__tests__/kube-api.test.ts +++ b/src/renderer/api/__tests__/kube-api.test.ts @@ -79,4 +79,4 @@ describe("KubeApi", () => { expect(kubeApi.apiPrefix).toEqual("/apis"); expect(kubeApi.apiGroup).toEqual("extensions"); }); -}); \ No newline at end of file +}); diff --git a/src/renderer/api/api-manager.ts b/src/renderer/api/api-manager.ts index 629a0f29c2..47500adf79 100644 --- a/src/renderer/api/api-manager.ts +++ b/src/renderer/api/api-manager.ts @@ -2,7 +2,7 @@ import type { KubeObjectStore } from "../kube-object.store"; import { action, observable } from "mobx"; import { autobind } from "../utils"; -import { KubeApi } from "./kube-api"; +import { KubeApi, parseKubeApi } from "./kube-api"; @autobind() export class ApiManager { @@ -11,7 +11,7 @@ export class ApiManager { getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) { 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)); diff --git a/src/renderer/api/endpoints/helm-charts.api.ts b/src/renderer/api/endpoints/helm-charts.api.ts index a1fd497798..8beff01779 100644 --- a/src/renderer/api/endpoints/helm-charts.api.ts +++ b/src/renderer/api/endpoints/helm-charts.api.ts @@ -86,7 +86,7 @@ export class HelmChart { tillerVersion?: string; getId() { - return this.digest; + return `${this.apiVersion}/${this.name}@${this.getAppVersion()}`; } getName() { diff --git a/src/renderer/api/endpoints/index.ts b/src/renderer/api/endpoints/index.ts index f1202b9122..5ab54e7c3a 100644 --- a/src/renderer/api/endpoints/index.ts +++ b/src/renderer/api/endpoints/index.ts @@ -14,6 +14,7 @@ export * from "./events.api"; export * from "./hpa.api"; export * from "./ingress.api"; export * from "./job.api"; +export * from "./limit-range.api"; export * from "./namespaces.api"; export * from "./network-policy.api"; export * from "./nodes.api"; diff --git a/src/renderer/api/endpoints/limit-range.api.ts b/src/renderer/api/endpoints/limit-range.api.ts new file mode 100644 index 0000000000..bbb3941c87 --- /dev/null +++ b/src/renderer/api/endpoints/limit-range.api.ts @@ -0,0 +1,57 @@ +import { KubeObject } from "../kube-object"; +import { KubeApi } from "../kube-api"; +import { autobind } from "../../utils"; + +export enum LimitType { + CONTAINER = "Container", + POD = "Pod", + PVC = "PersistentVolumeClaim", +} + +export enum Resource { + MEMORY = "memory", + CPU = "cpu", + STORAGE = "storage", + EPHEMERAL_STORAGE = "ephemeral-storage", +} + +export enum LimitPart { + MAX = "max", + MIN = "min", + DEFAULT = "default", + DEFAULT_REQUEST = "defaultRequest", + MAX_LIMIT_REQUEST_RATIO = "maxLimitRequestRatio", +} + +type LimitRangeParts = Partial>>; + +export interface LimitRangeItem extends LimitRangeParts { + type: string +} + +@autobind() +export class LimitRange extends KubeObject { + static kind = "LimitRange"; + static namespaced = true; + static apiBase = "/api/v1/limitranges"; + + spec: { + limits: LimitRangeItem[]; + }; + + getContainerLimits() { + return this.spec.limits.filter(limit => limit.type === LimitType.CONTAINER); + } + + getPodLimits() { + return this.spec.limits.filter(limit => limit.type === LimitType.POD); + } + + getPVCLimits() { + return this.spec.limits.filter(limit => limit.type === LimitType.PVC); + } +} + +export const limitRangeApi = new KubeApi({ + objectConstructor: LimitRange, +}); diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index 8a3a2517c2..e62603b14f 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -92,14 +92,6 @@ export function ensureObjectSelfLink(api: KubeApi, object: KubeJsonApiData) { } export class KubeApi { - static parseApi = parseKubeApi; - - static watchAll(...apis: KubeApi[]) { - const disposers = apis.map(api => api.watch()); - - return () => disposers.forEach(unwatch => unwatch()); - } - readonly kind: string; readonly apiBase: string; readonly apiPrefix: string; @@ -124,7 +116,7 @@ export class KubeApi { if (!options.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.isNamespaced = isNamespaced; @@ -157,7 +149,7 @@ export class KubeApi { for (const apiUrl of apiBases) { // 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 try { @@ -366,7 +358,7 @@ export class KubeApi { } watch(): () => void { - return kubeWatchApi.subscribe(this); + return kubeWatchApi.subscribeApi(this); } } diff --git a/src/renderer/api/kube-json-api.ts b/src/renderer/api/kube-json-api.ts index 3026a9a956..362ee5438e 100644 --- a/src/renderer/api/kube-json-api.ts +++ b/src/renderer/api/kube-json-api.ts @@ -21,7 +21,7 @@ export interface KubeJsonApiData extends JsonApiData { resourceVersion: string; continue?: string; finalizers?: string[]; - selfLink: string; + selfLink?: string; labels?: { [label: string]: string; }; diff --git a/src/renderer/api/kube-watch-api.ts b/src/renderer/api/kube-watch-api.ts index 78ca25256e..8adf58676f 100644 --- a/src/renderer/api/kube-watch-api.ts +++ b/src/renderer/api/kube-watch-api.ts @@ -1,184 +1,349 @@ -// 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 { Cluster } from "../../main/cluster"; +import type { IKubeWatchEvent, IKubeWatchEventStreamEnd, IWatchRoutePayload } from "../../main/routes/watch-route"; +import type { KubeObject } from "./kube-object"; import type { KubeObjectStore } from "../kube-object.store"; -import { ensureObjectSelfLink, KubeApi } from "./kube-api"; +import type { NamespaceStore } from "../components/+namespaces/namespace.store"; + +import plimit from "p-limit"; +import debounce from "lodash/debounce"; +import { comparer, computed, observable, reaction } from "mobx"; +import { autobind, EventEmitter } from "../utils"; +import { ensureObjectSelfLink, KubeApi, parseKubeApi } from "./kube-api"; +import { KubeJsonApiData, KubeJsonApiError } from "./kube-json-api"; +import { apiPrefix, isDebugging, isProduction } from "../../common/vars"; import { apiManager } from "./api-manager"; -import { apiPrefix, isDevelopment } from "../../common/vars"; -import { getHostedCluster } from "../../common/cluster-store"; -export interface IKubeWatchEvent { - type: "ADDED" | "MODIFIED" | "DELETED"; - object?: T; +export { IKubeWatchEvent, IKubeWatchEventStreamEnd }; + +export interface IKubeWatchMessage { + data?: IKubeWatchEvent + error?: IKubeWatchEvent; + api?: KubeApi; + store?: KubeObjectStore; } -export interface IKubeWatchRouteEvent { - type: "STREAM_END"; - url: string; - status: number; +export interface IKubeWatchSubscribeStoreOptions { + preload?: boolean; // preload store items, default: true + waitUntilLoaded?: boolean; // subscribe only after loading all stores, default: true + cacheLoading?: boolean; // when enabled loading store will be skipped, default: false } -export interface IKubeWatchRouteQuery { - api: string | string[]; +export interface IKubeWatchReconnectOptions { + reconnectAttempts: number; + timeout: number; +} + +export interface IKubeWatchLog { + message: string | Error; + meta?: object; } @autobind() export class KubeWatchApi { - protected evtSource: EventSource; - protected onData = new EventEmitter<[IKubeWatchEvent]>(); - protected subscribers = observable.map(); - protected reconnectTimeoutMs = 5000; - protected maxReconnectsOnError = 10; - protected reconnectAttempts = this.maxReconnectsOnError; + private cluster: Cluster; + private namespaceStore: NamespaceStore; - constructor() { - reaction(() => this.activeApis, () => this.connect(), { - fireImmediately: true, - delay: 500, - }); + private requestId = 0; + private isConnected = false; + private reader: ReadableStreamReader; + private subscribers = observable.map(); + + // events + public onMessage = new EventEmitter<[IKubeWatchMessage]>(); + + @computed get isActive(): boolean { + return this.apis.length > 0; } - @computed get activeApis() { - return Array.from(this.subscribers.keys()); + @computed get apis(): string[] { + const { cluster, namespaceStore } = this; + const activeApis = Array.from(this.subscribers.keys()); + + return activeApis.map(api => { + if (!cluster.isAllowedResource(api.kind)) { + return []; + } + + if (api.isNamespaced) { + return namespaceStore.getContextNamespaces().map(namespace => api.getWatchUrl(namespace)); + } else { + return api.getWatchUrl(); + } + }).flat(); + } + + constructor() { + this.init(); + } + + private async init() { + const { getHostedCluster } = await import("../../common/cluster-store"); + const { namespaceStore } = await import("../components/+namespaces/namespace.store"); + + await namespaceStore.whenReady; + + this.cluster = getHostedCluster(); + this.namespaceStore = namespaceStore; + this.bindAutoConnect(); + } + + private bindAutoConnect() { + const connect = debounce(() => this.connect(), 1000); + + reaction(() => this.apis, connect, { + fireImmediately: true, + equals: comparer.structural, + }); + + window.addEventListener("online", () => this.connect()); + window.addEventListener("offline", () => this.disconnect()); + setInterval(() => this.connectionCheck(), 60000 * 5); // every 5m } getSubscribersCount(api: KubeApi) { return this.subscribers.get(api) || 0; } - subscribe(...apis: KubeApi[]) { + isAllowedApi(api: KubeApi): boolean { + return !!this?.cluster.isAllowedResource(api.kind); + } + + subscribeApi(api: KubeApi | KubeApi[]): () => void { + const apis: KubeApi[] = [api].flat(); + apis.forEach(api => { + if (!this.isAllowedApi(api)) return; // skip this.subscribers.set(api, this.getSubscribersCount(api) + 1); }); - return () => apis.forEach(api => { - const count = this.getSubscribersCount(api) - 1; + return () => { + apis.forEach(api => { + const count = this.getSubscribersCount(api) - 1; - if (count <= 0) this.subscribers.delete(api); - else this.subscribers.set(api, count); - }); - } - - protected getQuery(): Partial { - const { isAdmin, allowedNamespaces } = getHostedCluster(); - - return { - api: this.activeApis.map(api => { - if (isAdmin) return api.getWatchUrl(); - - return allowedNamespaces.map(namespace => api.getWatchUrl(namespace)); - }).flat() + if (count <= 0) this.subscribers.delete(api); + else this.subscribers.set(api, count); + }); }; } - // todo: maybe switch to websocket to avoid often reconnects - @autobind() - protected connect() { - if (this.evtSource) this.disconnect(); // close previous connection + subscribeStores(stores: KubeObjectStore[], options: IKubeWatchSubscribeStoreOptions = {}): () => void { + const { preload = true, waitUntilLoaded = true, cacheLoading = false } = options; + const limitRequests = plimit(1); // load stores one by one to allow quick skipping when fast clicking btw pages + const preloading: Promise[] = []; + const apis = new Set(stores.map(store => store.getSubscribeApis()).flat()); + const unsubscribeList: (() => void)[] = []; + let isUnsubscribed = false; - if (!this.activeApis.length) { - return; + const subscribe = () => { + if (isUnsubscribed) return; + apis.forEach(api => unsubscribeList.push(this.subscribeApi(api))); + }; + + if (preload) { + for (const store of stores) { + preloading.push(limitRequests(async () => { + if (cacheLoading && store.isLoaded) return; // skip + + return store.loadAll(); + })); + } } - const query = this.getQuery(); - const apiUrl = `${apiPrefix}/watch?${stringify(query)}`; - this.evtSource = new EventSource(apiUrl); - this.evtSource.onmessage = this.onMessage; - this.evtSource.onerror = this.onError; - this.writeLog("CONNECTING", query.api); + if (waitUntilLoaded) { + Promise.all(preloading).then(subscribe, error => { + this.log({ + message: new Error("Loading stores has failed"), + meta: { stores, error, options }, + }); + }); + } else { + subscribe(); + } + + // unsubscribe + return () => { + if (isUnsubscribed) return; + isUnsubscribed = true; + limitRequests.clearQueue(); + unsubscribeList.forEach(unsubscribe => unsubscribe()); + }; } - reconnect() { - if (!this.evtSource || this.evtSource.readyState !== EventSource.OPEN) { - this.reconnectAttempts = this.maxReconnectsOnError; - this.connect(); + protected async connectionCheck() { + if (!this.isConnected) { + this.log({ message: "Offline: reconnecting.." }); + await this.connect(); + } + + this.log({ + message: `Connection check: ${this.isConnected ? "online" : "offline"}`, + meta: { connected: this.isConnected }, + }); + } + + protected async connect(apis = this.apis) { + this.disconnect(); // close active connections first + + if (!navigator.onLine || !apis.length) { + this.isConnected = false; + + return; + } + + this.log({ + message: "Connecting", + meta: { apis } + }); + + try { + const requestId = ++this.requestId; + const abortController = new AbortController(); + + const request = await fetch(`${apiPrefix}/watch`, { + method: "POST", + body: JSON.stringify({ apis } as IWatchRoutePayload), + signal: abortController.signal, + headers: { + "content-type": "application/json" + } + }); + + // request above is stale since new request-id has been issued + if (this.requestId !== requestId) { + abortController.abort(); + + return; + } + + let jsonBuffer = ""; + const stream = request.body.pipeThrough(new TextDecoderStream()); + const reader = stream.getReader(); + + this.isConnected = true; + this.reader = reader; + + while (true) { + const { done, value } = await reader.read(); + + if (done) break; // exit + + const events = (jsonBuffer + value).split("\n"); + + jsonBuffer = this.processBuffer(events); + } + } catch (error) { + this.log({ message: error }); + } finally { + this.isConnected = false; } } protected disconnect() { - if (!this.evtSource) return; - this.evtSource.close(); - this.evtSource.onmessage = null; - this.evtSource = null; + this.reader?.cancel(); + this.reader = null; + this.isConnected = false; } - protected onMessage(evt: MessageEvent) { - if (!evt.data) return; - const data = JSON.parse(evt.data); + // process received stream events, returns unprocessed buffer chunk if any + protected processBuffer(events: string[]): string { + for (const json of events) { + try { + const kubeEvent: IKubeWatchEvent = JSON.parse(json); + const message = this.getMessage(kubeEvent); - if ((data as IKubeWatchEvent).object) { - this.onData.emit(data); - } else { - this.onRouteEvent(data); + this.onMessage.emit(message); + } catch (error) { + return json; + } } + + return ""; } - protected async onRouteEvent(event: IKubeWatchRouteEvent) { - if (event.type === "STREAM_END") { - this.disconnect(); - const { apiBase, namespace } = KubeApi.parseApi(event.url); - const api = apiManager.getApi(apiBase); + protected getMessage(event: IKubeWatchEvent): IKubeWatchMessage { + const message: IKubeWatchMessage = {}; - if (api) { - try { - await api.refreshResourceVersion({ namespace }); - this.reconnect(); - } catch (error) { - console.error("failed to refresh resource version", error); + switch (event.type) { + case "ADDED": + case "DELETED": - if (this.subscribers.size > 0) { - setTimeout(() => { - this.onRouteEvent(event); - }, 1000); - } + case "MODIFIED": { + const data = event as IKubeWatchEvent; + const api = apiManager.getApiByKind(data.object.kind, data.object.apiVersion); + + message.data = data; + + if (api) { + ensureObjectSelfLink(api, data.object); + + const { namespace, resourceVersion } = data.object.metadata; + + api.setResourceVersion(namespace, resourceVersion); + api.setResourceVersion("", resourceVersion); + + message.api = api; + message.store = apiManager.getStore(api); } + break; + } + + case "ERROR": + message.error = event as IKubeWatchEvent; + break; + + case "STREAM_END": { + this.onServerStreamEnd(event as IKubeWatchEventStreamEnd, { + reconnectAttempts: 5, + timeout: 1000, + }); + break; + } + } + + return message; + } + + protected async onServerStreamEnd(event: IKubeWatchEventStreamEnd, opts?: IKubeWatchReconnectOptions) { + const { apiBase, namespace } = parseKubeApi(event.url); + const api = apiManager.getApi(apiBase); + + if (!api) return; + + try { + await api.refreshResourceVersion({ namespace }); + this.connect(); + } catch (error) { + this.log({ + message: new Error(`Failed to connect on single stream end: ${error}`), + meta: { event, error }, + }); + + if (this.isActive && opts?.reconnectAttempts > 0) { + opts.reconnectAttempts--; + setTimeout(() => this.onServerStreamEnd(event, opts), opts.timeout); // repeat event } } } - protected onError(evt: MessageEvent) { - const { reconnectAttempts: attemptsRemain, reconnectTimeoutMs } = this; - - if (evt.eventPhase === EventSource.CLOSED) { - if (attemptsRemain > 0) { - this.reconnectAttempts--; - setTimeout(() => this.connect(), reconnectTimeoutMs); - } + protected log({ message, meta = {} }: IKubeWatchLog) { + if (isProduction && !isDebugging) { + return; } - } - protected writeLog(...data: any[]) { - if (isDevelopment) { - console.log("%cKUBE-WATCH-API:", `font-weight: bold`, ...data); + const logMessage = `%c[KUBE-WATCH-API]: ${String(message).toUpperCase()}`; + const isError = message instanceof Error; + const textStyle = `font-weight: bold;`; + const time = new Date().toLocaleString(); + + if (isError) { + console.error(logMessage, textStyle, { time, ...meta }); + } else { + console.info(logMessage, textStyle, { time, ...meta }); } } - - addListener(store: KubeObjectStore, callback: (evt: IKubeWatchEvent) => void) { - const listener = (evt: IKubeWatchEvent) => { - 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); - - return () => this.onData.removeListener(listener); - } - - reset() { - this.subscribers.clear(); - } } export const kubeWatchApi = new KubeWatchApi(); diff --git a/src/renderer/api/workload-kube-object.ts b/src/renderer/api/workload-kube-object.ts index e0c6d3f121..185d3d502c 100644 --- a/src/renderer/api/workload-kube-object.ts +++ b/src/renderer/api/workload-kube-object.ts @@ -1,7 +1,7 @@ import get from "lodash/get"; import { KubeObject } from "./kube-object"; -interface IToleration { +export interface IToleration { key?: string; operator?: string; effect?: string; @@ -82,4 +82,4 @@ export class WorkloadKubeObject extends KubeObject { return Object.keys(affinity).length; } -} \ No newline at end of file +} diff --git a/src/renderer/components/+apps-helm-charts/helm-charts.route.ts b/src/renderer/components/+apps-helm-charts/helm-charts.route.ts index 9b8aecc499..97a0923d97 100644 --- a/src/renderer/components/+apps-helm-charts/helm-charts.route.ts +++ b/src/renderer/components/+apps-helm-charts/helm-charts.route.ts @@ -11,4 +11,4 @@ export interface IHelmChartsRouteParams { repo?: string; } -export const helmChartsURL = buildURL(helmChartsRoute.path); \ No newline at end of file +export const helmChartsURL = buildURL(helmChartsRoute.path); diff --git a/src/renderer/components/+apps-helm-charts/helm-charts.tsx b/src/renderer/components/+apps-helm-charts/helm-charts.tsx index 8bf5486a55..348ce00969 100644 --- a/src/renderer/components/+apps-helm-charts/helm-charts.tsx +++ b/src/renderer/components/+apps-helm-charts/helm-charts.tsx @@ -11,8 +11,11 @@ import { navigation } from "../../navigation"; import { ItemListLayout } from "../item-object-list/item-list-layout"; import { SearchInputUrl } from "../input"; -enum sortBy { +enum columnId { name = "name", + description = "description", + version = "version", + appVersion = "app-version", repo = "repo", } @@ -53,13 +56,15 @@ export class HelmCharts extends Component { return ( <> chart.getName(), - [sortBy.repo]: (chart: HelmChart) => chart.getRepository(), + [columnId.name]: (chart: HelmChart) => chart.getName(), + [columnId.repo]: (chart: HelmChart) => chart.getRepository(), }} searchFilters={[ (chart: HelmChart) => chart.getName(), @@ -74,13 +79,12 @@ export class HelmCharts extends Component { )} renderTableHeader={[ - { className: "icon" }, - { title: "Name", className: "name", sortBy: sortBy.name }, - { title: "Description", className: "description" }, - { title: "Version", className: "version" }, - { title: "App Version", className: "app-version" }, - { title: "Repository", className: "repository", sortBy: sortBy.repo }, - + { className: "icon", showWithColumn: columnId.name }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { title: "Description", className: "description", id: columnId.description }, + { title: "Version", className: "version", id: columnId.version }, + { title: "App Version", className: "app-version", id: columnId.appVersion }, + { title: "Repository", className: "repository", sortBy: columnId.repo, id: columnId.repo }, ]} renderTableContents={(chart: HelmChart) => [
@@ -93,7 +97,8 @@ export class HelmCharts extends Component { chart.getDescription(), chart.getVersion(), chart.getAppVersion(), - { title: chart.getRepository(), className: chart.getRepository().toLowerCase() } + { title: chart.getRepository(), className: chart.getRepository().toLowerCase() }, + { className: "menu" } ]} detailsItem={this.selectedChart} onDetails={this.showDetails} diff --git a/src/renderer/components/+apps-helm-charts/index.ts b/src/renderer/components/+apps-helm-charts/index.ts index a9403c097c..c0649f3f38 100644 --- a/src/renderer/components/+apps-helm-charts/index.ts +++ b/src/renderer/components/+apps-helm-charts/index.ts @@ -1,2 +1,2 @@ export * from "./helm-charts"; -export * from "./helm-charts.route"; \ No newline at end of file +export * from "./helm-charts.route"; diff --git a/src/renderer/components/+apps-releases/index.ts b/src/renderer/components/+apps-releases/index.ts index 32a4871769..bd80c60404 100644 --- a/src/renderer/components/+apps-releases/index.ts +++ b/src/renderer/components/+apps-releases/index.ts @@ -1,2 +1,2 @@ export * from "./releases"; -export * from "./release.route"; \ No newline at end of file +export * from "./release.route"; diff --git a/src/renderer/components/+apps-releases/release.store.ts b/src/renderer/components/+apps-releases/release.store.ts index b6d5c2fb5f..6f7ed39fed 100644 --- a/src/renderer/components/+apps-releases/release.store.ts +++ b/src/renderer/components/+apps-releases/release.store.ts @@ -5,7 +5,7 @@ import { HelmRelease, helmReleasesApi, IReleaseCreatePayload, IReleaseUpdatePayl import { ItemStore } from "../../item.store"; import { Secret } from "../../api/endpoints"; import { secretsStore } from "../+config-secrets/secrets.store"; -import { getHostedCluster } from "../../../common/cluster-store"; +import { namespaceStore } from "../+namespaces/namespace.store"; @autobind() export class ReleaseStore extends ItemStore { @@ -60,30 +60,23 @@ export class ReleaseStore extends ItemStore { @action async loadAll() { this.isLoading = true; - let items; try { - const { isAdmin, allowedNamespaces } = getHostedCluster(); + const items = await this.loadItems(namespaceStore.getContextNamespaces()); - items = await this.loadItems(!isAdmin ? allowedNamespaces : null); - } finally { - if (items) { - items = this.sortItems(items); - this.items.replace(items); - } + this.items.replace(this.sortItems(items)); this.isLoaded = true; + } catch (error) { + console.error(`Loading Helm Chart releases has failed: ${error}`); + } finally { this.isLoading = false; } } - async loadItems(namespaces?: string[]) { - if (!namespaces) { - return helmReleasesApi.list(); - } else { - return Promise - .all(namespaces.map(namespace => helmReleasesApi.list(namespace))) - .then(items => items.flat()); - } + async loadItems(namespaces: string[]) { + return Promise + .all(namespaces.map(namespace => helmReleasesApi.list(namespace))) + .then(items => items.flat()); } async create(payload: IReleaseCreatePayload) { diff --git a/src/renderer/components/+apps-releases/releases.tsx b/src/renderer/components/+apps-releases/releases.tsx index 709c6f9bbd..71cf3d954f 100644 --- a/src/renderer/components/+apps-releases/releases.tsx +++ b/src/renderer/components/+apps-releases/releases.tsx @@ -14,11 +14,13 @@ import { ItemListLayout } from "../item-object-list/item-list-layout"; import { HelmReleaseMenu } from "./release-menu"; import { secretsStore } from "../+config-secrets/secrets.store"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", revision = "revision", chart = "chart", + version = "version", + appVersion = "app-version", status = "status", updated = "update" } @@ -81,16 +83,18 @@ export class HelmReleases extends Component { return ( <> release.getName(), - [sortBy.namespace]: (release: HelmRelease) => release.getNs(), - [sortBy.revision]: (release: HelmRelease) => release.getRevision(), - [sortBy.chart]: (release: HelmRelease) => release.getChart(), - [sortBy.status]: (release: HelmRelease) => release.getStatus(), - [sortBy.updated]: (release: HelmRelease) => release.getUpdated(false, false), + [columnId.name]: (release: HelmRelease) => release.getName(), + [columnId.namespace]: (release: HelmRelease) => release.getNs(), + [columnId.revision]: (release: HelmRelease) => release.getRevision(), + [columnId.chart]: (release: HelmRelease) => release.getChart(), + [columnId.status]: (release: HelmRelease) => release.getStatus(), + [columnId.updated]: (release: HelmRelease) => release.getUpdated(false, false), }} searchFilters={[ (release: HelmRelease) => release.getName(), @@ -101,14 +105,14 @@ export class HelmReleases extends Component { ]} renderHeaderTitle="Releases" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Chart", className: "chart", sortBy: sortBy.chart }, - { title: "Revision", className: "revision", sortBy: sortBy.revision }, - { title: "Version", className: "version" }, - { title: "App Version", className: "app-version" }, - { title: "Status", className: "status", sortBy: sortBy.status }, - { title: "Updated", className: "updated", sortBy: sortBy.updated }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Chart", className: "chart", sortBy: columnId.chart, id: columnId.chart }, + { title: "Revision", className: "revision", sortBy: columnId.revision, id: columnId.revision }, + { title: "Version", className: "version", id: columnId.version }, + { title: "App Version", className: "app-version", id: columnId.appVersion }, + { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, + { title: "Updated", className: "updated", sortBy: columnId.updated, id: columnId.updated }, ]} renderTableContents={(release: HelmRelease) => { const version = release.getVersion(); diff --git a/src/renderer/components/+apps/index.ts b/src/renderer/components/+apps/index.ts index 70c0169777..330891b2b1 100644 --- a/src/renderer/components/+apps/index.ts +++ b/src/renderer/components/+apps/index.ts @@ -1,2 +1,2 @@ export * from "./apps"; -export * from "./apps.route"; \ No newline at end of file +export * from "./apps.route"; diff --git a/src/renderer/components/+cluster-settings/components/cluster-home-dir-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-home-dir-setting.tsx index 35c18cc5e5..10aabf3ff7 100644 --- a/src/renderer/components/+cluster-settings/components/cluster-home-dir-setting.tsx +++ b/src/renderer/components/+cluster-settings/components/cluster-home-dir-setting.tsx @@ -48,4 +48,4 @@ export class ClusterHomeDirSetting extends React.Component { ); } -} \ No newline at end of file +} diff --git a/src/renderer/components/+cluster-settings/components/cluster-name-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-name-setting.tsx index 631c6d54ef..9d953ef9ca 100644 --- a/src/renderer/components/+cluster-settings/components/cluster-name-setting.tsx +++ b/src/renderer/components/+cluster-settings/components/cluster-name-setting.tsx @@ -45,4 +45,4 @@ export class ClusterNameSetting extends React.Component { ); } -} \ No newline at end of file +} diff --git a/src/renderer/components/+cluster-settings/components/cluster-proxy-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-proxy-setting.tsx index 3887487816..eb122ac444 100644 --- a/src/renderer/components/+cluster-settings/components/cluster-proxy-setting.tsx +++ b/src/renderer/components/+cluster-settings/components/cluster-proxy-setting.tsx @@ -45,4 +45,4 @@ export class ClusterProxySetting extends React.Component { ); } -} \ No newline at end of file +} diff --git a/src/renderer/components/+cluster-settings/general.tsx b/src/renderer/components/+cluster-settings/general.tsx index 1d498bc94b..91fce05164 100644 --- a/src/renderer/components/+cluster-settings/general.tsx +++ b/src/renderer/components/+cluster-settings/general.tsx @@ -25,4 +25,4 @@ export class General extends React.Component { ; } -} \ No newline at end of file +} diff --git a/src/renderer/components/+cluster-settings/removal.tsx b/src/renderer/components/+cluster-settings/removal.tsx index 7d97e9c515..495fb71fe8 100644 --- a/src/renderer/components/+cluster-settings/removal.tsx +++ b/src/renderer/components/+cluster-settings/removal.tsx @@ -17,4 +17,4 @@ export class Removal extends React.Component { ); } -} \ No newline at end of file +} diff --git a/src/renderer/components/+cluster-settings/status.tsx b/src/renderer/components/+cluster-settings/status.tsx index 7f21d19aba..d43cfe5c35 100644 --- a/src/renderer/components/+cluster-settings/status.tsx +++ b/src/renderer/components/+cluster-settings/status.tsx @@ -58,4 +58,4 @@ export class Status extends React.Component { ; } -} \ No newline at end of file +} diff --git a/src/renderer/components/+cluster/cluster-issues.tsx b/src/renderer/components/+cluster/cluster-issues.tsx index eb85bf79f8..0aabebaa27 100644 --- a/src/renderer/components/+cluster/cluster-issues.tsx +++ b/src/renderer/components/+cluster/cluster-issues.tsx @@ -23,11 +23,13 @@ interface IWarning extends ItemObject { kind: string; message: string; selfLink: string; + age: string | number; } enum sortBy { type = "type", - object = "object" + object = "object", + age = "age", } @observer @@ -35,6 +37,7 @@ export class ClusterIssues extends React.Component { private sortCallbacks = { [sortBy.type]: (warning: IWarning) => warning.kind, [sortBy.object]: (warning: IWarning) => warning.getName(), + [sortBy.age]: (warning: IWarning) => warning.age || "", }; @computed get warnings() { @@ -42,15 +45,16 @@ export class ClusterIssues extends React.Component { // Node bad conditions nodesStore.items.forEach(node => { - const { kind, selfLink, getId, getName } = node; + const { kind, selfLink, getId, getName, getAge } = node; node.getWarningConditions().forEach(({ message }) => { warnings.push({ - kind, + age: getAge(), getId, getName, - selfLink, + kind, message, + selfLink, }); }); }); @@ -59,12 +63,13 @@ export class ClusterIssues extends React.Component { const events = eventStore.getWarnings(); events.forEach(error => { - const { message, involvedObject } = error; + const { message, involvedObject, getAge } = error; const { uid, name, kind } = involvedObject; warnings.push({ getId: () => uid, getName: () => name, + age: getAge(), message, kind, selfLink: lookupApiLink(involvedObject, error), @@ -78,7 +83,7 @@ export class ClusterIssues extends React.Component { getTableRow(uid: string) { const { warnings } = this; const warning = warnings.find(warn => warn.getId() == uid); - const { getId, getName, message, kind, selfLink } = warning; + const { getId, getName, message, kind, selfLink, age } = warning; return ( { {kind} + + {age} + ); } @@ -139,6 +147,7 @@ export class ClusterIssues extends React.Component { Message Object Type + Age diff --git a/src/renderer/components/+cluster/cluster-metrics.tsx b/src/renderer/components/+cluster/cluster-metrics.tsx index 6461bae7f3..2bdaded8b7 100644 --- a/src/renderer/components/+cluster/cluster-metrics.tsx +++ b/src/renderer/components/+cluster/cluster-metrics.tsx @@ -95,4 +95,4 @@ export const ClusterMetrics = observer(() => { {renderMetrics()} ); -}); \ No newline at end of file +}); diff --git a/src/renderer/components/+cluster/cluster-overview.tsx b/src/renderer/components/+cluster/cluster-overview.tsx index 104c6fd022..1f7a7f6b78 100644 --- a/src/renderer/components/+cluster/cluster-overview.tsx +++ b/src/renderer/components/+cluster/cluster-overview.tsx @@ -3,13 +3,9 @@ import "./cluster-overview.scss"; import React from "react"; import { reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; - -import { eventStore } from "../+events/event.store"; import { nodesStore } from "../+nodes/nodes.store"; import { podsStore } from "../+workloads-pods/pods.store"; import { getHostedCluster } from "../../../common/cluster-store"; -import { isAllowedResource } from "../../../common/rbac"; -import { KubeObjectStore } from "../../kube-object.store"; import { interval } from "../../utils"; import { TabLayout } from "../layout/tab-layout"; import { Spinner } from "../spinner"; @@ -17,45 +13,33 @@ import { ClusterIssues } from "./cluster-issues"; import { ClusterMetrics } from "./cluster-metrics"; import { clusterOverviewStore } from "./cluster-overview.store"; import { ClusterPieCharts } from "./cluster-pie-charts"; +import { eventStore } from "../+events/event.store"; +import { kubeWatchApi } from "../../api/kube-watch-api"; @observer export class ClusterOverview extends React.Component { - private stores: KubeObjectStore[] = []; - private subscribers: Array<() => void> = []; - private metricPoller = interval(60, this.loadMetrics); - - @disposeOnUnmount - fetchMetrics = reaction( - () => clusterOverviewStore.metricNodeRole, // Toggle Master/Worker node switcher - () => this.metricPoller.restart(true) - ); + private metricPoller = interval(60, () => this.loadMetrics()); loadMetrics() { getHostedCluster().available && clusterOverviewStore.loadMetrics(); } - async componentDidMount() { - if (isAllowedResource("nodes")) { - this.stores.push(nodesStore); - } + componentDidMount() { + this.metricPoller.start(true); - if (isAllowedResource("pods")) { - this.stores.push(podsStore); - } + disposeOnUnmount(this, [ + kubeWatchApi.subscribeStores([nodesStore, podsStore, eventStore], { + preload: 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(); + reaction( + () => clusterOverviewStore.metricNodeRole, // Toggle Master/Worker node switcher + () => this.metricPoller.restart(true) + ), + ]); } componentWillUnmount() { - this.subscribers.forEach(dispose => dispose()); // unsubscribe all this.metricPoller.stop(); } diff --git a/src/renderer/components/+config-autoscalers/hpa.tsx b/src/renderer/components/+config-autoscalers/hpa.tsx index 023a28f156..2e2a78fc82 100644 --- a/src/renderer/components/+config-autoscalers/hpa.tsx +++ b/src/renderer/components/+config-autoscalers/hpa.tsx @@ -11,13 +11,15 @@ import { Badge } from "../badge"; import { cssNames } from "../../utils"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", + metrics = "metrics", minPods = "min-pods", maxPods = "max-pods", replicas = "replicas", age = "age", + status = "status" } interface Props extends RouteComponentProps { @@ -37,28 +39,30 @@ export class HorizontalPodAutoscalers extends React.Component { render() { return ( item.getName(), - [sortBy.namespace]: (item: HorizontalPodAutoscaler) => item.getNs(), - [sortBy.minPods]: (item: HorizontalPodAutoscaler) => item.getMinPods(), - [sortBy.maxPods]: (item: HorizontalPodAutoscaler) => item.getMaxPods(), - [sortBy.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas() + [columnId.name]: (item: HorizontalPodAutoscaler) => item.getName(), + [columnId.namespace]: (item: HorizontalPodAutoscaler) => item.getNs(), + [columnId.minPods]: (item: HorizontalPodAutoscaler) => item.getMinPods(), + [columnId.maxPods]: (item: HorizontalPodAutoscaler) => item.getMaxPods(), + [columnId.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas() }} searchFilters={[ (item: HorizontalPodAutoscaler) => item.getSearchFields() ]} renderHeaderTitle="Horizontal Pod Autoscalers" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Metrics", className: "metrics" }, - { title: "Min Pods", className: "min-pods", sortBy: sortBy.minPods }, - { title: "Max Pods", className: "max-pods", sortBy: sortBy.maxPods }, - { title: "Replicas", className: "replicas", sortBy: sortBy.replicas }, - { title: "Age", className: "age", sortBy: sortBy.age }, - { title: "Status", className: "status" }, + { title: "Name", className: "name", sortBy: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Metrics", className: "metrics", id: columnId.metrics }, + { title: "Min Pods", className: "min-pods", sortBy: columnId.minPods, id: columnId.minPods }, + { title: "Max Pods", className: "max-pods", sortBy: columnId.maxPods, id: columnId.maxPods }, + { title: "Replicas", className: "replicas", sortBy: columnId.replicas, id: columnId.replicas }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Status", className: "status", id: columnId.status }, ]} renderTableContents={(hpa: HorizontalPodAutoscaler) => [ hpa.getName(), diff --git a/src/renderer/components/+config-limit-ranges/index.ts b/src/renderer/components/+config-limit-ranges/index.ts new file mode 100644 index 0000000000..53308bdd18 --- /dev/null +++ b/src/renderer/components/+config-limit-ranges/index.ts @@ -0,0 +1,3 @@ +export * from "./limit-ranges"; +export * from "./limit-ranges.route"; +export * from "./limit-range-details"; diff --git a/src/renderer/components/+config-limit-ranges/limit-range-details.scss b/src/renderer/components/+config-limit-ranges/limit-range-details.scss new file mode 100644 index 0000000000..ff39ec514a --- /dev/null +++ b/src/renderer/components/+config-limit-ranges/limit-range-details.scss @@ -0,0 +1,12 @@ +.LimitRangeDetails { + + .DrawerItem { + > .name { + font-weight: $font-weight-normal; + padding-left: 4px; + } + .DrawerItem { + padding-top: 4px; + } + } +} diff --git a/src/renderer/components/+config-limit-ranges/limit-range-details.tsx b/src/renderer/components/+config-limit-ranges/limit-range-details.tsx new file mode 100644 index 0000000000..ab35505cc6 --- /dev/null +++ b/src/renderer/components/+config-limit-ranges/limit-range-details.tsx @@ -0,0 +1,97 @@ +import "./limit-range-details.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import { KubeObjectDetailsProps } from "../kube-object"; +import { LimitPart, LimitRange, LimitRangeItem, Resource } from "../../api/endpoints/limit-range.api"; +import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; +import { KubeObjectMeta } from "../kube-object/kube-object-meta"; +import { DrawerItem } from "../drawer/drawer-item"; +import { Badge } from "../badge"; + +interface Props extends KubeObjectDetailsProps { +} + +function renderLimit(limit: LimitRangeItem, part: LimitPart, resource: Resource) { + + const resourceLimit = limit[part]?.[resource]; + + if (!resourceLimit) { + return null; + } + + return ; +} + +function renderResourceLimits(limit: LimitRangeItem, resource: Resource) { + return ( + + {renderLimit(limit, LimitPart.MIN, resource)} + {renderLimit(limit, LimitPart.MAX, resource)} + {renderLimit(limit, LimitPart.DEFAULT, resource)} + {renderLimit(limit, LimitPart.DEFAULT_REQUEST, resource)} + {renderLimit(limit, LimitPart.MAX_LIMIT_REQUEST_RATIO, resource)} + + ); +} + +function renderLimitDetails(limits: LimitRangeItem[], resources: Resource[]) { + + return resources.map(resource => + + { + limits.map(limit => + renderResourceLimits(limit, resource) + ) + } + + ); +} + +@observer +export class LimitRangeDetails extends React.Component { + render() { + const { object: limitRange } = this.props; + + if (!limitRange) return null; + const containerLimits = limitRange.getContainerLimits(); + const podLimits = limitRange.getPodLimits(); + const pvcLimits = limitRange.getPVCLimits(); + + return ( +
+ + + {containerLimits.length > 0 && + + { + renderLimitDetails(containerLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE]) + } + + } + {podLimits.length > 0 && + + { + renderLimitDetails(podLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE]) + } + + } + {pvcLimits.length > 0 && + + { + renderLimitDetails(pvcLimits, [Resource.STORAGE]) + } + + } +
+ ); + } +} + +kubeObjectDetailRegistry.add({ + kind: "LimitRange", + apiVersions: ["v1"], + components: { + Details: (props) => + } +}); diff --git a/src/renderer/components/+config-limit-ranges/limit-ranges.route.ts b/src/renderer/components/+config-limit-ranges/limit-ranges.route.ts new file mode 100644 index 0000000000..09e3052350 --- /dev/null +++ b/src/renderer/components/+config-limit-ranges/limit-ranges.route.ts @@ -0,0 +1,11 @@ +import type { RouteProps } from "react-router"; +import { buildURL } from "../../../common/utils/buildUrl"; + +export const limitRangesRoute: RouteProps = { + path: "/limitranges" +}; + +export interface LimitRangeRouteParams { +} + +export const limitRangeURL = buildURL(limitRangesRoute.path); diff --git a/src/renderer/components/+config-limit-ranges/limit-ranges.scss b/src/renderer/components/+config-limit-ranges/limit-ranges.scss new file mode 100644 index 0000000000..e5de19acfb --- /dev/null +++ b/src/renderer/components/+config-limit-ranges/limit-ranges.scss @@ -0,0 +1,7 @@ +.LimitRanges { + .TableCell { + &.warning { + @include table-cell-warning; + } + } +} diff --git a/src/renderer/components/+config-limit-ranges/limit-ranges.store.ts b/src/renderer/components/+config-limit-ranges/limit-ranges.store.ts new file mode 100644 index 0000000000..bd760efadd --- /dev/null +++ b/src/renderer/components/+config-limit-ranges/limit-ranges.store.ts @@ -0,0 +1,12 @@ +import { autobind } from "../../../common/utils/autobind"; +import { KubeObjectStore } from "../../kube-object.store"; +import { apiManager } from "../../api/api-manager"; +import { LimitRange, limitRangeApi } from "../../api/endpoints/limit-range.api"; + +@autobind() +export class LimitRangesStore extends KubeObjectStore { + api = limitRangeApi; +} + +export const limitRangeStore = new LimitRangesStore(); +apiManager.registerStore(limitRangeStore); diff --git a/src/renderer/components/+config-limit-ranges/limit-ranges.tsx b/src/renderer/components/+config-limit-ranges/limit-ranges.tsx new file mode 100644 index 0000000000..a3b111929a --- /dev/null +++ b/src/renderer/components/+config-limit-ranges/limit-ranges.tsx @@ -0,0 +1,55 @@ +import "./limit-ranges.scss"; + +import { RouteComponentProps } from "react-router"; +import { observer } from "mobx-react"; +import { KubeObjectListLayout } from "../kube-object/kube-object-list-layout"; +import { limitRangeStore } from "./limit-ranges.store"; +import { LimitRangeRouteParams } from "./limit-ranges.route"; +import React from "react"; +import { KubeObjectStatusIcon } from "../kube-object-status-icon"; +import { LimitRange } from "../../api/endpoints/limit-range.api"; + +enum columnId { + name = "name", + namespace = "namespace", + age = "age", +} + +interface Props extends RouteComponentProps { +} + +@observer +export class LimitRanges extends React.Component { + render() { + return ( + item.getName(), + [columnId.namespace]: (item: LimitRange) => item.getNs(), + [columnId.age]: (item: LimitRange) => item.metadata.creationTimestamp, + }} + searchFilters={[ + (item: LimitRange) => item.getName(), + (item: LimitRange) => item.getNs(), + ]} + renderHeaderTitle={"Limit Ranges"} + renderTableHeader={[ + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + ]} + renderTableContents={(limitRange: LimitRange) => [ + limitRange.getName(), + , + limitRange.getNs(), + limitRange.getAge(), + ]} + /> + ); + } +} diff --git a/src/renderer/components/+config-maps/config-maps.tsx b/src/renderer/components/+config-maps/config-maps.tsx index 128c583fc8..532532bf53 100644 --- a/src/renderer/components/+config-maps/config-maps.tsx +++ b/src/renderer/components/+config-maps/config-maps.tsx @@ -9,7 +9,7 @@ import { KubeObjectListLayout } from "../kube-object"; import { IConfigMapsRouteParams } from "./config-maps.route"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", keys = "keys", @@ -24,12 +24,14 @@ export class ConfigMaps extends React.Component { render() { return ( item.getName(), - [sortBy.namespace]: (item: ConfigMap) => item.getNs(), - [sortBy.keys]: (item: ConfigMap) => item.getKeys(), - [sortBy.age]: (item: ConfigMap) => item.metadata.creationTimestamp, + [columnId.name]: (item: ConfigMap) => item.getName(), + [columnId.namespace]: (item: ConfigMap) => item.getNs(), + [columnId.keys]: (item: ConfigMap) => item.getKeys(), + [columnId.age]: (item: ConfigMap) => item.metadata.creationTimestamp, }} searchFilters={[ (item: ConfigMap) => item.getSearchFields(), @@ -37,11 +39,11 @@ export class ConfigMaps extends React.Component { ]} renderHeaderTitle="Config Maps" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Keys", className: "keys", sortBy: sortBy.keys }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Keys", className: "keys", sortBy: columnId.keys, id: columnId.keys }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(configMap: ConfigMap) => [ configMap.getName(), diff --git a/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx b/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx index f0754e0be8..8136225f11 100644 --- a/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx +++ b/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx @@ -7,7 +7,7 @@ import { PodDisruptionBudget } from "../../api/endpoints/poddisruptionbudget.api import { KubeObjectDetailsProps, KubeObjectListLayout } from "../kube-object"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", minAvailable = "min-available", @@ -25,30 +25,32 @@ export class PodDisruptionBudgets extends React.Component { render() { return ( pdb.getName(), - [sortBy.namespace]: (pdb: PodDisruptionBudget) => pdb.getNs(), - [sortBy.minAvailable]: (pdb: PodDisruptionBudget) => pdb.getMinAvailable(), - [sortBy.maxUnavailable]: (pdb: PodDisruptionBudget) => pdb.getMaxUnavailable(), - [sortBy.currentHealthy]: (pdb: PodDisruptionBudget) => pdb.getCurrentHealthy(), - [sortBy.desiredHealthy]: (pdb: PodDisruptionBudget) => pdb.getDesiredHealthy(), - [sortBy.age]: (pdb: PodDisruptionBudget) => pdb.getAge(), + [columnId.name]: (pdb: PodDisruptionBudget) => pdb.getName(), + [columnId.namespace]: (pdb: PodDisruptionBudget) => pdb.getNs(), + [columnId.minAvailable]: (pdb: PodDisruptionBudget) => pdb.getMinAvailable(), + [columnId.maxUnavailable]: (pdb: PodDisruptionBudget) => pdb.getMaxUnavailable(), + [columnId.currentHealthy]: (pdb: PodDisruptionBudget) => pdb.getCurrentHealthy(), + [columnId.desiredHealthy]: (pdb: PodDisruptionBudget) => pdb.getDesiredHealthy(), + [columnId.age]: (pdb: PodDisruptionBudget) => pdb.getAge(), }} searchFilters={[ (pdb: PodDisruptionBudget) => pdb.getSearchFields(), ]} renderHeaderTitle="Pod Disruption Budgets" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Min Available", className: "min-available", sortBy: sortBy.minAvailable }, - { title: "Max Unavailable", className: "max-unavailable", sortBy: sortBy.maxUnavailable }, - { title: "Current Healthy", className: "current-healthy", sortBy: sortBy.currentHealthy }, - { title: "Desired Healthy", className: "desired-healthy", sortBy: sortBy.desiredHealthy }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Min Available", className: "min-available", sortBy: columnId.minAvailable, id: columnId.minAvailable }, + { title: "Max Unavailable", className: "max-unavailable", sortBy: columnId.maxUnavailable, id: columnId.maxUnavailable }, + { title: "Current Healthy", className: "current-healthy", sortBy: columnId.currentHealthy, id: columnId.currentHealthy }, + { title: "Desired Healthy", className: "desired-healthy", sortBy: columnId.desiredHealthy, id: columnId.desiredHealthy }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(pdb: PodDisruptionBudget) => { return [ diff --git a/src/renderer/components/+config-resource-quotas/resource-quotas.tsx b/src/renderer/components/+config-resource-quotas/resource-quotas.tsx index 1aed2c9d24..5adfef2edc 100644 --- a/src/renderer/components/+config-resource-quotas/resource-quotas.tsx +++ b/src/renderer/components/+config-resource-quotas/resource-quotas.tsx @@ -10,7 +10,7 @@ import { resourceQuotaStore } from "./resource-quotas.store"; import { IResourceQuotaRouteParams } from "./resource-quotas.route"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", age = "age" @@ -25,11 +25,13 @@ export class ResourceQuotas extends React.Component { return ( <> item.getName(), - [sortBy.namespace]: (item: ResourceQuota) => item.getNs(), - [sortBy.age]: (item: ResourceQuota) => item.metadata.creationTimestamp, + [columnId.name]: (item: ResourceQuota) => item.getName(), + [columnId.namespace]: (item: ResourceQuota) => item.getNs(), + [columnId.age]: (item: ResourceQuota) => item.metadata.creationTimestamp, }} searchFilters={[ (item: ResourceQuota) => item.getSearchFields(), @@ -37,10 +39,10 @@ export class ResourceQuotas extends React.Component { ]} renderHeaderTitle="Resource Quotas" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(resourceQuota: ResourceQuota) => [ resourceQuota.getName(), diff --git a/src/renderer/components/+config-secrets/secrets.tsx b/src/renderer/components/+config-secrets/secrets.tsx index f2c88fda58..60158cfb55 100644 --- a/src/renderer/components/+config-secrets/secrets.tsx +++ b/src/renderer/components/+config-secrets/secrets.tsx @@ -11,7 +11,7 @@ import { Badge } from "../badge"; import { secretsStore } from "./secrets.store"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", labels = "labels", @@ -29,14 +29,16 @@ export class Secrets extends React.Component { return ( <> item.getName(), - [sortBy.namespace]: (item: Secret) => item.getNs(), - [sortBy.labels]: (item: Secret) => item.getLabels(), - [sortBy.keys]: (item: Secret) => item.getKeys(), - [sortBy.type]: (item: Secret) => item.type, - [sortBy.age]: (item: Secret) => item.metadata.creationTimestamp, + [columnId.name]: (item: Secret) => item.getName(), + [columnId.namespace]: (item: Secret) => item.getNs(), + [columnId.labels]: (item: Secret) => item.getLabels(), + [columnId.keys]: (item: Secret) => item.getKeys(), + [columnId.type]: (item: Secret) => item.type, + [columnId.age]: (item: Secret) => item.metadata.creationTimestamp, }} searchFilters={[ (item: Secret) => item.getSearchFields(), @@ -44,13 +46,13 @@ export class Secrets extends React.Component { ]} renderHeaderTitle="Secrets" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Labels", className: "labels", sortBy: sortBy.labels }, - { title: "Keys", className: "keys", sortBy: sortBy.keys }, - { title: "Type", className: "type", sortBy: sortBy.type }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Labels", className: "labels", sortBy: columnId.labels, id: columnId.labels }, + { title: "Keys", className: "keys", sortBy: columnId.keys, id: columnId.keys }, + { title: "Type", className: "type", sortBy: columnId.type, id: columnId.type }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(secret: Secret) => [ secret.getName(), diff --git a/src/renderer/components/+config/config.tsx b/src/renderer/components/+config/config.tsx index bb70dd3fb5..e3158459ba 100644 --- a/src/renderer/components/+config/config.tsx +++ b/src/renderer/components/+config/config.tsx @@ -8,6 +8,7 @@ import { resourceQuotaRoute, ResourceQuotas, resourceQuotaURL } from "../+config import { pdbRoute, pdbURL, PodDisruptionBudgets } from "../+config-pod-disruption-budgets"; import { HorizontalPodAutoscalers, hpaRoute, hpaURL } from "../+config-autoscalers"; import { isAllowedResource } from "../../../common/rbac"; +import { LimitRanges, limitRangesRoute, limitRangeURL } from "../+config-limit-ranges"; @observer export class Config extends React.Component { @@ -42,6 +43,15 @@ export class Config extends React.Component { }); } + if (isAllowedResource("limitranges")) { + routes.push({ + title: "Limit Ranges", + component: LimitRanges, + url: limitRangeURL({ query }), + routePath: limitRangesRoute.path.toString(), + }); + } + if (isAllowedResource("horizontalpodautoscalers")) { routes.push({ title: "HPA", diff --git a/src/renderer/components/+custom-resources/crd-list.tsx b/src/renderer/components/+custom-resources/crd-list.tsx index 8868231235..f8b77c09a9 100644 --- a/src/renderer/components/+custom-resources/crd-list.tsx +++ b/src/renderer/components/+custom-resources/crd-list.tsx @@ -19,7 +19,7 @@ export const crdGroupsUrlParam = createPageParam({ defaultValue: [], }); -enum sortBy { +enum columnId { kind = "kind", group = "group", version = "version", @@ -47,14 +47,16 @@ export class CrdList extends React.Component { render() { const selectedGroups = this.groups; const sortingCallbacks = { - [sortBy.kind]: (crd: CustomResourceDefinition) => crd.getResourceKind(), - [sortBy.group]: (crd: CustomResourceDefinition) => crd.getGroup(), - [sortBy.version]: (crd: CustomResourceDefinition) => crd.getVersion(), - [sortBy.scope]: (crd: CustomResourceDefinition) => crd.getScope(), + [columnId.kind]: (crd: CustomResourceDefinition) => crd.getResourceKind(), + [columnId.group]: (crd: CustomResourceDefinition) => crd.getGroup(), + [columnId.version]: (crd: CustomResourceDefinition) => crd.getVersion(), + [columnId.scope]: (crd: CustomResourceDefinition) => crd.getScope(), }; return ( [ diff --git a/src/renderer/components/+custom-resources/crd-resources.tsx b/src/renderer/components/+custom-resources/crd-resources.tsx index e6a7f2aac6..b9008b410d 100644 --- a/src/renderer/components/+custom-resources/crd-resources.tsx +++ b/src/renderer/components/+custom-resources/crd-resources.tsx @@ -16,7 +16,7 @@ import { parseJsonPath } from "../../utils/jsonPath"; interface Props extends RouteComponentProps { } -enum sortBy { +enum columnId { name = "name", namespace = "namespace", age = "age", @@ -55,9 +55,9 @@ export class CrdResources extends React.Component { const isNamespaced = crd.isNamespaced(); const extraColumns = crd.getPrinterColumns(false); // Cols with priority bigger than 0 are shown in details const sortingCallbacks: { [sortBy: string]: TableSortCallback } = { - [sortBy.name]: (item: KubeObject) => item.getName(), - [sortBy.namespace]: (item: KubeObject) => item.getNs(), - [sortBy.age]: (item: KubeObject) => item.metadata.creationTimestamp, + [columnId.name]: (item: KubeObject) => item.getName(), + [columnId.namespace]: (item: KubeObject) => item.getNs(), + [columnId.age]: (item: KubeObject) => item.metadata.creationTimestamp, }; extraColumns.forEach(column => { @@ -66,6 +66,8 @@ export class CrdResources extends React.Component { return ( { ]} renderHeaderTitle={crd.getResourceTitle()} renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - isNamespaced && { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + isNamespaced && { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, ...extraColumns.map(column => { const { name } = column; return { title: name, 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) => [ crdInstance.getName(), diff --git a/src/renderer/components/+events/event.store.ts b/src/renderer/components/+events/event.store.ts index 3651ce1549..d6090be947 100644 --- a/src/renderer/components/+events/event.store.ts +++ b/src/renderer/components/+events/event.store.ts @@ -52,6 +52,10 @@ export class EventStore extends KubeObjectStore { return compact(eventsWithError); } + + getWarningsCount() { + return this.getWarnings().length; + } } export const eventStore = new EventStore(); diff --git a/src/renderer/components/+events/events.tsx b/src/renderer/components/+events/events.tsx index c4e6920bc8..8e2af5d9a8 100644 --- a/src/renderer/components/+events/events.tsx +++ b/src/renderer/components/+events/events.tsx @@ -12,11 +12,13 @@ import { cssNames, IClassName, stopPropagation } from "../../utils"; import { Icon } from "../icon"; import { lookupApiLink } from "../../api/kube-api"; -enum sortBy { +enum columnId { + message = "message", namespace = "namespace", object = "object", type = "type", count = "count", + source = "source", age = "age", } @@ -39,15 +41,17 @@ export class Events extends React.Component { const events = ( event.getNs(), - [sortBy.type]: (event: KubeEvent) => event.involvedObject.kind, - [sortBy.object]: (event: KubeEvent) => event.involvedObject.name, - [sortBy.count]: (event: KubeEvent) => event.count, - [sortBy.age]: (event: KubeEvent) => event.metadata.creationTimestamp, + [columnId.namespace]: (event: KubeEvent) => event.getNs(), + [columnId.type]: (event: KubeEvent) => event.involvedObject.kind, + [columnId.object]: (event: KubeEvent) => event.involvedObject.name, + [columnId.count]: (event: KubeEvent) => event.count, + [columnId.age]: (event: KubeEvent) => event.metadata.creationTimestamp, }} searchFilters={[ (event: KubeEvent) => event.getSearchFields(), @@ -72,13 +76,13 @@ export class Events extends React.Component { }) )} renderTableHeader={[ - { title: "Message", className: "message" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Type", className: "type", sortBy: sortBy.type }, - { title: "Involved Object", className: "object", sortBy: sortBy.object }, - { title: "Source", className: "source" }, - { title: "Count", className: "count", sortBy: sortBy.count }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Message", className: "message", id: columnId.message }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Type", className: "type", sortBy: columnId.type, id: columnId.type }, + { title: "Involved Object", className: "object", sortBy: columnId.object, id: columnId.object }, + { title: "Source", className: "source", id: columnId.source }, + { title: "Count", className: "count", sortBy: columnId.count, id: columnId.count }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(event: KubeEvent) => { const { involvedObject, type, message } = event; diff --git a/src/renderer/components/+landing-page/index.tsx b/src/renderer/components/+landing-page/index.tsx index 4bdb2a706c..c7eacf1bd0 100644 --- a/src/renderer/components/+landing-page/index.tsx +++ b/src/renderer/components/+landing-page/index.tsx @@ -1,2 +1,2 @@ export * from "./landing-page.route"; -export * from "./landing-page"; \ No newline at end of file +export * from "./landing-page"; diff --git a/src/renderer/components/+namespaces/namespace-details.tsx b/src/renderer/components/+namespaces/namespace-details.tsx index f687630a96..5dfa93cea7 100644 --- a/src/renderer/components/+namespaces/namespace-details.tsx +++ b/src/renderer/components/+namespaces/namespace-details.tsx @@ -12,6 +12,7 @@ import { Spinner } from "../spinner"; import { resourceQuotaStore } from "../+config-resource-quotas/resource-quotas.store"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; +import { limitRangeStore } from "../+config-limit-ranges/limit-ranges.store"; interface Props extends KubeObjectDetailsProps { } @@ -24,8 +25,15 @@ export class NamespaceDetails extends React.Component { return resourceQuotaStore.getAllByNs(namespace); } + @computed get limitranges() { + const namespace = this.props.object.getName(); + + return limitRangeStore.getAllByNs(namespace); + } + componentDidMount() { resourceQuotaStore.loadAll(); + limitRangeStore.loadAll(); } render() { @@ -52,6 +60,16 @@ export class NamespaceDetails extends React.Component { ); })} + + {!this.limitranges && limitRangeStore.isLoading && } + {this.limitranges.map(limitrange => { + return ( + + {limitrange.getName()} + + ); + })} + ); } diff --git a/src/renderer/components/+namespaces/namespace-select.tsx b/src/renderer/components/+namespaces/namespace-select.tsx index 079a9cd0b6..6ee7ea2d57 100644 --- a/src/renderer/components/+namespaces/namespace-select.tsx +++ b/src/renderer/components/+namespaces/namespace-select.tsx @@ -2,13 +2,14 @@ import "./namespace-select.scss"; import React from "react"; import { computed } from "mobx"; -import { observer } from "mobx-react"; +import { disposeOnUnmount, observer } from "mobx-react"; import { Select, SelectOption, SelectProps } from "../select"; -import { cssNames, noop } from "../../utils"; +import { cssNames } from "../../utils"; import { Icon } from "../icon"; import { namespaceStore } from "./namespace.store"; import { FilterIcon } from "../item-object-list/filter-icon"; import { FilterType } from "../item-object-list/page-filters.store"; +import { kubeWatchApi } from "../../api/kube-watch-api"; interface Props extends SelectProps { showIcons?: boolean; @@ -28,17 +29,13 @@ const defaultProps: Partial = { @observer export class NamespaceSelect extends React.Component { static defaultProps = defaultProps as object; - private unsubscribe = noop; - async componentDidMount() { - if (!namespaceStore.isLoaded) { - await namespaceStore.loadAll(); - } - this.unsubscribe = namespaceStore.subscribe(); - } - - componentWillUnmount() { - this.unsubscribe(); + componentDidMount() { + disposeOnUnmount(this, [ + kubeWatchApi.subscribeStores([namespaceStore], { + preload: true, + }) + ]); } @computed get options(): SelectOption[] { @@ -60,7 +57,7 @@ export class NamespaceSelect extends React.Component { return label || ( <> - {showIcons && } + {showIcons && } {value} ); @@ -103,9 +100,9 @@ export class NamespaceSelectFilter extends React.Component { return (
- + {namespace} - {isSelected && } + {isSelected && }
); }} diff --git a/src/renderer/components/+namespaces/namespace.store.ts b/src/renderer/components/+namespaces/namespace.store.ts index ad02dd137c..63bb7525de 100644 --- a/src/renderer/components/+namespaces/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace.store.ts @@ -1,91 +1,145 @@ -import { action, comparer, observable, reaction } from "mobx"; +import { action, comparer, IReactionDisposer, IReactionOptions, observable, reaction, toJS, when } from "mobx"; import { autobind, createStorage } from "../../utils"; -import { KubeObjectStore } from "../../kube-object.store"; -import { Namespace, namespacesApi } from "../../api/endpoints"; +import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store"; +import { Namespace, namespacesApi } from "../../api/endpoints/namespaces.api"; import { createPageParam } from "../../navigation"; import { apiManager } from "../../api/api-manager"; -import { isAllowedResource } from "../../../common/rbac"; -import { getHostedCluster } from "../../../common/cluster-store"; +import { clusterStore, getHostedCluster } from "../../../common/cluster-store"; -const storage = createStorage("context_namespaces", []); +const storage = createStorage("context_namespaces"); export const namespaceUrlParam = createPageParam({ name: "namespaces", isSystem: true, multiValues: true, get defaultValue() { - return storage.get(); // initial namespaces coming from URL or local-storage (default) + return storage.get() ?? []; // initial namespaces coming from URL or local-storage (default) } }); +export function getDummyNamespace(name: string) { + return new Namespace({ + kind: Namespace.kind, + apiVersion: "v1", + metadata: { + name, + uid: "", + resourceVersion: "", + selfLink: `/api/v1/namespaces/${name}` + } + }); +} + @autobind() export class NamespaceStore extends KubeObjectStore { api = namespacesApi; - contextNs = observable.array(); + + @observable contextNs = observable.array(); + @observable isReady = false; + + whenReady = when(() => this.isReady); constructor() { super(); this.init(); } - private init() { - this.setContext(this.initNamespaces); + private async init() { + await clusterStore.whenLoaded; + if (!getHostedCluster()) return; + await getHostedCluster().whenReady; // wait for cluster-state from main - return reaction(() => this.contextNs.toJS(), namespaces => { + this.setContext(this.initialNamespaces); + this.autoLoadAllowedNamespaces(); + this.autoUpdateUrlAndLocalStorage(); + + this.isReady = true; + } + + public onContextChange(callback: (contextNamespaces: string[]) => void, opts: IReactionOptions = {}): IReactionDisposer { + return reaction(() => this.contextNs.toJS(), callback, { + equals: comparer.shallow, + ...opts, + }); + } + + private autoUpdateUrlAndLocalStorage(): IReactionDisposer { + return this.onContextChange(namespaces => { storage.set(namespaces); // save to local-storage namespaceUrlParam.set(namespaces, { replaceHistory: true }); // update url }, { fireImmediately: true, - equals: comparer.identity, }); } - get initNamespaces() { - return namespaceUrlParam.get(); + private autoLoadAllowedNamespaces(): IReactionDisposer { + return reaction(() => this.allowedNamespaces, () => this.loadAll(), { + fireImmediately: true, + equals: comparer.shallow, + }); } - getContextParams() { - return { - namespaces: this.contextNs.toJS(), - }; + get allowedNamespaces(): string[] { + return toJS(getHostedCluster().allowedNamespaces); } - subscribe(apis = [this.api]) { + private get initialNamespaces(): string[] { + const allowed = new Set(this.allowedNamespaces); + const prevSelected = storage.get(); + + if (Array.isArray(prevSelected)) { + return prevSelected.filter(namespace => allowed.has(namespace)); + } + + // otherwise select "default" or first allowed namespace + if (allowed.has("default")) { + return ["default"]; + } else if (allowed.size) { + return [Array.from(allowed)[0]]; + } + + return []; + } + + getContextNamespaces(): string[] { + const namespaces = this.contextNs.toJS(); + + // show all namespaces when nothing selected + if (!namespaces.length) { + if (this.isLoaded) { + // return actual namespaces list since "allowedNamespaces" updating every 30s in cluster and thus might be stale + return this.items.map(namespace => namespace.getName()); + } + + return this.allowedNamespaces; + } + + return namespaces; + } + + getSubscribeApis() { const { accessibleNamespaces } = getHostedCluster(); // if user has given static list of namespaces let's not start watches because watch adds stuff that's not wanted if (accessibleNamespaces.length > 0) { - return Function; // no-op - } - - return super.subscribe(apis); - } - - protected async loadItems(namespaces?: string[]) { - if (!isAllowedResource("namespaces")) { - if (namespaces) return namespaces.map(this.getDummyNamespace); - return []; } - if (namespaces) { - return Promise.all(namespaces.map(name => this.api.get({ name }))); - } else { - return super.loadItems(); - } + return super.getSubscribeApis(); } - protected getDummyNamespace(name: string) { - return new Namespace({ - kind: "Namespace", - apiVersion: "v1", - metadata: { - name, - uid: "", - resourceVersion: "", - selfLink: `/api/v1/namespaces/${name}` - } - }); + protected async loadItems(params: KubeObjectStoreLoadingParams) { + const { allowedNamespaces } = this; + + let namespaces = await super.loadItems(params); + + namespaces = namespaces.filter(namespace => allowedNamespaces.includes(namespace.getName())); + + if (!namespaces.length && allowedNamespaces.length > 0) { + return allowedNamespaces.map(getDummyNamespace); + } + + return namespaces; } @action @@ -105,12 +159,6 @@ export class NamespaceStore extends KubeObjectStore { else this.contextNs.push(namespace); } - @action - reset() { - super.reset(); - this.contextNs.clear(); - } - @action async remove(item: Namespace) { await super.remove(item); diff --git a/src/renderer/components/+namespaces/namespaces.tsx b/src/renderer/components/+namespaces/namespaces.tsx index f097657493..3972f3d180 100644 --- a/src/renderer/components/+namespaces/namespaces.tsx +++ b/src/renderer/components/+namespaces/namespaces.tsx @@ -11,7 +11,7 @@ import { INamespacesRouteParams } from "./namespaces.route"; import { namespaceStore } from "./namespace.store"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", labels = "labels", age = "age", @@ -27,12 +27,14 @@ export class Namespaces extends React.Component { ns.getName(), - [sortBy.labels]: (ns: Namespace) => ns.getLabels(), - [sortBy.age]: (ns: Namespace) => ns.metadata.creationTimestamp, - [sortBy.status]: (ns: Namespace) => ns.getStatus(), + [columnId.name]: (ns: Namespace) => ns.getName(), + [columnId.labels]: (ns: Namespace) => ns.getLabels(), + [columnId.age]: (ns: Namespace) => ns.metadata.creationTimestamp, + [columnId.status]: (ns: Namespace) => ns.getStatus(), }} searchFilters={[ (item: Namespace) => item.getSearchFields(), @@ -40,11 +42,11 @@ export class Namespaces extends React.Component { ]} renderHeaderTitle="Namespaces" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Labels", className: "labels", sortBy: sortBy.labels }, - { title: "Age", className: "age", sortBy: sortBy.age }, - { title: "Status", className: "status", sortBy: sortBy.status }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Labels", className: "labels", sortBy: columnId.labels, id: columnId.labels }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, ]} renderTableContents={(item: Namespace) => [ item.getName(), diff --git a/src/renderer/components/+network-endpoints/endpoints.tsx b/src/renderer/components/+network-endpoints/endpoints.tsx index 3b859c46f3..ce87c14a4a 100644 --- a/src/renderer/components/+network-endpoints/endpoints.tsx +++ b/src/renderer/components/+network-endpoints/endpoints.tsx @@ -9,9 +9,10 @@ import { endpointStore } from "./endpoints.store"; import { KubeObjectListLayout } from "../kube-object"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", + endpoints = "endpoints", age = "age", } @@ -23,22 +24,24 @@ export class Endpoints extends React.Component { render() { return ( endpoint.getName(), - [sortBy.namespace]: (endpoint: Endpoint) => endpoint.getNs(), - [sortBy.age]: (endpoint: Endpoint) => endpoint.metadata.creationTimestamp, + [columnId.name]: (endpoint: Endpoint) => endpoint.getName(), + [columnId.namespace]: (endpoint: Endpoint) => endpoint.getNs(), + [columnId.age]: (endpoint: Endpoint) => endpoint.metadata.creationTimestamp, }} searchFilters={[ (endpoint: Endpoint) => endpoint.getSearchFields() ]} renderHeaderTitle="Endpoints" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Endpoints", className: "endpoints" }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Endpoints", className: "endpoints", id: columnId.endpoints }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(endpoint: Endpoint) => [ endpoint.getName(), diff --git a/src/renderer/components/+network-ingresses/ingresses.tsx b/src/renderer/components/+network-ingresses/ingresses.tsx index adb6c84528..945f2b8f0a 100644 --- a/src/renderer/components/+network-ingresses/ingresses.tsx +++ b/src/renderer/components/+network-ingresses/ingresses.tsx @@ -9,9 +9,11 @@ import { ingressStore } from "./ingress.store"; import { KubeObjectListLayout } from "../kube-object"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", + loadBalancers ="load-balancers", + rules = "rules", age = "age", } @@ -23,11 +25,13 @@ export class Ingresses extends React.Component { render() { return ( ingress.getName(), - [sortBy.namespace]: (ingress: Ingress) => ingress.getNs(), - [sortBy.age]: (ingress: Ingress) => ingress.metadata.creationTimestamp, + [columnId.name]: (ingress: Ingress) => ingress.getName(), + [columnId.namespace]: (ingress: Ingress) => ingress.getNs(), + [columnId.age]: (ingress: Ingress) => ingress.metadata.creationTimestamp, }} searchFilters={[ (ingress: Ingress) => ingress.getSearchFields(), @@ -35,12 +39,12 @@ export class Ingresses extends React.Component { ]} renderHeaderTitle="Ingresses" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "LoadBalancers", className: "loadbalancers" }, - { title: "Rules", className: "rules" }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "LoadBalancers", className: "loadbalancers", id: columnId.loadBalancers }, + { title: "Rules", className: "rules", id: columnId.rules }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(ingress: Ingress) => [ ingress.getName(), diff --git a/src/renderer/components/+network-policies/network-policies.tsx b/src/renderer/components/+network-policies/network-policies.tsx index d4dc0e2fa9..6899c14558 100644 --- a/src/renderer/components/+network-policies/network-policies.tsx +++ b/src/renderer/components/+network-policies/network-policies.tsx @@ -9,9 +9,10 @@ import { INetworkPoliciesRouteParams } from "./network-policies.route"; import { networkPolicyStore } from "./network-policy.store"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", + types = "types", age = "age", } @@ -23,22 +24,24 @@ export class NetworkPolicies extends React.Component { render() { return ( item.getName(), - [sortBy.namespace]: (item: NetworkPolicy) => item.getNs(), - [sortBy.age]: (item: NetworkPolicy) => item.metadata.creationTimestamp, + [columnId.name]: (item: NetworkPolicy) => item.getName(), + [columnId.namespace]: (item: NetworkPolicy) => item.getNs(), + [columnId.age]: (item: NetworkPolicy) => item.metadata.creationTimestamp, }} searchFilters={[ (item: NetworkPolicy) => item.getSearchFields(), ]} renderHeaderTitle="Network Policies" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Policy Types", className: "type" }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Policy Types", className: "type", id: columnId.types }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(item: NetworkPolicy) => [ item.getName(), diff --git a/src/renderer/components/+network-services/service-details.tsx b/src/renderer/components/+network-services/service-details.tsx index 58cbe0a86e..80c9bd4dc7 100644 --- a/src/renderer/components/+network-services/service-details.tsx +++ b/src/renderer/components/+network-services/service-details.tsx @@ -1,17 +1,18 @@ import "./service-details.scss"; import React from "react"; -import { observer } from "mobx-react"; +import { disposeOnUnmount, observer } from "mobx-react"; import { DrawerItem, DrawerTitle } from "../drawer"; import { Badge } from "../badge"; import { KubeEventDetails } from "../+events/kube-event-details"; import { KubeObjectDetailsProps } from "../kube-object"; -import { Service, endpointApi } from "../../api/endpoints"; +import { Service } from "../../api/endpoints"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { ServicePortComponent } from "./service-port-component"; import { endpointStore } from "../+network-endpoints/endpoints.store"; import { ServiceDetailsEndpoint } from "./service-details-endpoint"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; +import { kubeWatchApi } from "../../api/kube-watch-api"; interface Props extends KubeObjectDetailsProps { } @@ -19,10 +20,11 @@ interface Props extends KubeObjectDetailsProps { @observer export class ServiceDetails extends React.Component { componentDidMount() { - if (!endpointStore.isLoaded) { - endpointStore.loadAll(); - } - endpointApi.watch(); + disposeOnUnmount(this, [ + kubeWatchApi.subscribeStores([endpointStore], { + preload: true, + }), + ]); } render() { @@ -77,7 +79,7 @@ export class ServiceDetails extends React.Component { )} - + ); } diff --git a/src/renderer/components/+network-services/services.tsx b/src/renderer/components/+network-services/services.tsx index 3452c10a68..740e0bfdf1 100644 --- a/src/renderer/components/+network-services/services.tsx +++ b/src/renderer/components/+network-services/services.tsx @@ -10,12 +10,13 @@ import { Badge } from "../badge"; import { serviceStore } from "./services.store"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", selector = "selector", ports = "port", clusterIp = "cluster-ip", + externalIp = "external-ip", age = "age", type = "type", status = "status", @@ -29,16 +30,18 @@ export class Services extends React.Component { render() { return ( service.getName(), - [sortBy.namespace]: (service: Service) => service.getNs(), - [sortBy.selector]: (service: Service) => service.getSelector(), - [sortBy.ports]: (service: Service) => (service.spec.ports || []).map(({ port }) => port)[0], - [sortBy.clusterIp]: (service: Service) => service.getClusterIp(), - [sortBy.type]: (service: Service) => service.getType(), - [sortBy.age]: (service: Service) => service.metadata.creationTimestamp, - [sortBy.status]: (service: Service) => service.getStatus(), + [columnId.name]: (service: Service) => service.getName(), + [columnId.namespace]: (service: Service) => service.getNs(), + [columnId.selector]: (service: Service) => service.getSelector(), + [columnId.ports]: (service: Service) => (service.spec.ports || []).map(({ port }) => port)[0], + [columnId.clusterIp]: (service: Service) => service.getClusterIp(), + [columnId.type]: (service: Service) => service.getType(), + [columnId.age]: (service: Service) => service.metadata.creationTimestamp, + [columnId.status]: (service: Service) => service.getStatus(), }} searchFilters={[ (service: Service) => service.getSearchFields(), @@ -47,16 +50,16 @@ export class Services extends React.Component { ]} renderHeaderTitle="Services" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Type", className: "type", sortBy: sortBy.type }, - { title: "Cluster IP", className: "clusterIp", sortBy: sortBy.clusterIp, }, - { title: "Ports", className: "ports", sortBy: sortBy.ports }, - { title: "External IP", className: "externalIp" }, - { title: "Selector", className: "selector", sortBy: sortBy.selector }, - { title: "Age", className: "age", sortBy: sortBy.age }, - { title: "Status", className: "status", sortBy: sortBy.status }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Type", className: "type", sortBy: columnId.type, id: columnId.type }, + { title: "Cluster IP", className: "clusterIp", sortBy: columnId.clusterIp, id: columnId.clusterIp }, + { title: "Ports", className: "ports", sortBy: columnId.ports, id: columnId.ports }, + { title: "External IP", className: "externalIp", id: columnId.externalIp }, + { title: "Selector", className: "selector", sortBy: columnId.selector, id: columnId.selector }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, ]} renderTableContents={(service: Service) => [ service.getName(), diff --git a/src/renderer/components/+nodes/nodes.store.ts b/src/renderer/components/+nodes/nodes.store.ts index c0385b078b..b301015747 100644 --- a/src/renderer/components/+nodes/nodes.store.ts +++ b/src/renderer/components/+nodes/nodes.store.ts @@ -1,3 +1,4 @@ +import { sum } from "lodash"; import { action, computed, observable } from "mobx"; import { clusterApi, IClusterMetrics, INodeMetrics, Node, nodesApi } from "../../api/endpoints"; import { autobind } from "../../utils"; @@ -62,6 +63,10 @@ export class NodesStore extends KubeObjectStore { }); } + getWarningsCount(): number { + return sum(this.items.map((node: Node) => node.getWarningConditions().length)); + } + reset() { super.reset(); this.metrics = {}; diff --git a/src/renderer/components/+nodes/nodes.tsx b/src/renderer/components/+nodes/nodes.tsx index edfa5c4026..32da6a13db 100644 --- a/src/renderer/components/+nodes/nodes.tsx +++ b/src/renderer/components/+nodes/nodes.tsx @@ -17,7 +17,7 @@ import upperFirst from "lodash/upperFirst"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { Badge } from "../badge/badge"; -enum sortBy { +enum columnId { name = "name", cpu = "cpu", memory = "memory", @@ -51,6 +51,10 @@ export class Nodes extends React.Component { if (!metrics || !metrics[1]) return ; const usage = metrics[0]; const cores = metrics[1]; + const cpuUsagePercent = Math.ceil(usage * 100) / cores; + const cpuUsagePercentLabel: String = cpuUsagePercent % 1 === 0 + ? cpuUsagePercent.toString() + : cpuUsagePercent.toFixed(2); return ( { value={usage} tooltip={{ preferredPositions: TooltipPosition.BOTTOM, - children: `CPU: ${Math.ceil(usage * 100) / cores}\%, cores: ${cores}` + children: `CPU: ${cpuUsagePercentLabel}\%, cores: ${cores}` }} /> ); @@ -131,21 +135,23 @@ export class Nodes extends React.Component { return ( node.getName(), - [sortBy.cpu]: (node: Node) => nodesStore.getLastMetricValues(node, ["cpuUsage"]), - [sortBy.memory]: (node: Node) => nodesStore.getLastMetricValues(node, ["memoryUsage"]), - [sortBy.disk]: (node: Node) => nodesStore.getLastMetricValues(node, ["fsUsage"]), - [sortBy.conditions]: (node: Node) => node.getNodeConditionText(), - [sortBy.taints]: (node: Node) => node.getTaints().length, - [sortBy.roles]: (node: Node) => node.getRoleLabels(), - [sortBy.age]: (node: Node) => node.metadata.creationTimestamp, - [sortBy.version]: (node: Node) => node.getKubeletVersion(), + [columnId.name]: (node: Node) => node.getName(), + [columnId.cpu]: (node: Node) => nodesStore.getLastMetricValues(node, ["cpuUsage"]), + [columnId.memory]: (node: Node) => nodesStore.getLastMetricValues(node, ["memoryUsage"]), + [columnId.disk]: (node: Node) => nodesStore.getLastMetricValues(node, ["fsUsage"]), + [columnId.conditions]: (node: Node) => node.getNodeConditionText(), + [columnId.taints]: (node: Node) => node.getTaints().length, + [columnId.roles]: (node: Node) => node.getRoleLabels(), + [columnId.age]: (node: Node) => node.metadata.creationTimestamp, + [columnId.version]: (node: Node) => node.getKubeletVersion(), }} searchFilters={[ (node: Node) => node.getSearchFields(), @@ -155,16 +161,16 @@ export class Nodes extends React.Component { ]} renderHeaderTitle="Nodes" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "CPU", className: "cpu", sortBy: sortBy.cpu }, - { title: "Memory", className: "memory", sortBy: sortBy.memory }, - { title: "Disk", className: "disk", sortBy: sortBy.disk }, - { title: "Taints", className: "taints", sortBy: sortBy.taints }, - { title: "Roles", className: "roles", sortBy: sortBy.roles }, - { title: "Version", className: "version", sortBy: sortBy.version }, - { title: "Age", className: "age", sortBy: sortBy.age }, - { title: "Conditions", className: "conditions", sortBy: sortBy.conditions }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "CPU", className: "cpu", sortBy: columnId.cpu, id: columnId.cpu }, + { title: "Memory", className: "memory", sortBy: columnId.memory, id: columnId.memory }, + { title: "Disk", className: "disk", sortBy: columnId.disk, id: columnId.disk }, + { title: "Taints", className: "taints", sortBy: columnId.taints, id: columnId.taints }, + { title: "Roles", className: "roles", sortBy: columnId.roles, id: columnId.roles }, + { title: "Version", className: "version", sortBy: columnId.version, id: columnId.version }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Conditions", className: "conditions", sortBy: columnId.conditions, id: columnId.conditions }, ]} renderTableContents={(node: Node) => { const tooltipId = `node-taints-${node.getId()}`; diff --git a/src/renderer/components/+pod-security-policies/index.ts b/src/renderer/components/+pod-security-policies/index.ts index d037873b5b..c9379d3381 100644 --- a/src/renderer/components/+pod-security-policies/index.ts +++ b/src/renderer/components/+pod-security-policies/index.ts @@ -1,3 +1,3 @@ export * from "./pod-security-policies.route"; export * from "./pod-security-policies"; -export * from "./pod-security-policy-details"; \ No newline at end of file +export * from "./pod-security-policy-details"; diff --git a/src/renderer/components/+pod-security-policies/pod-security-policies.tsx b/src/renderer/components/+pod-security-policies/pod-security-policies.tsx index 30ec1d6304..a91e0114d6 100644 --- a/src/renderer/components/+pod-security-policies/pod-security-policies.tsx +++ b/src/renderer/components/+pod-security-policies/pod-security-policies.tsx @@ -7,7 +7,7 @@ import { podSecurityPoliciesStore } from "./pod-security-policies.store"; import { PodSecurityPolicy } from "../../api/endpoints"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", volumes = "volumes", privileged = "privileged", @@ -19,14 +19,16 @@ export class PodSecurityPolicies extends React.Component { render() { return ( item.getName(), - [sortBy.volumes]: (item: PodSecurityPolicy) => item.getVolumes(), - [sortBy.privileged]: (item: PodSecurityPolicy) => +item.isPrivileged(), - [sortBy.age]: (item: PodSecurityPolicy) => item.metadata.creationTimestamp, + [columnId.name]: (item: PodSecurityPolicy) => item.getName(), + [columnId.volumes]: (item: PodSecurityPolicy) => item.getVolumes(), + [columnId.privileged]: (item: PodSecurityPolicy) => +item.isPrivileged(), + [columnId.age]: (item: PodSecurityPolicy) => item.metadata.creationTimestamp, }} searchFilters={[ (item: PodSecurityPolicy) => item.getSearchFields(), @@ -35,11 +37,11 @@ export class PodSecurityPolicies extends React.Component { ]} renderHeaderTitle="Pod Security Policies" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Privileged", className: "privileged", sortBy: sortBy.privileged }, - { title: "Volumes", className: "volumes", sortBy: sortBy.volumes }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Privileged", className: "privileged", sortBy: columnId.privileged, id: columnId.privileged }, + { title: "Volumes", className: "volumes", sortBy: columnId.volumes, id: columnId.volumes }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(item: PodSecurityPolicy) => { return [ diff --git a/src/renderer/components/+storage-classes/storage-classes.tsx b/src/renderer/components/+storage-classes/storage-classes.tsx index ec7e1c8e05..1a8ed346fd 100644 --- a/src/renderer/components/+storage-classes/storage-classes.tsx +++ b/src/renderer/components/+storage-classes/storage-classes.tsx @@ -9,10 +9,11 @@ import { IStorageClassesRouteParams } from "./storage-classes.route"; import { storageClassStore } from "./storage-class.store"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", age = "age", provisioner = "provision", + default = "default", reclaimPolicy = "reclaim", } @@ -24,13 +25,15 @@ export class StorageClasses extends React.Component { render() { return ( item.getName(), - [sortBy.age]: (item: StorageClass) => item.metadata.creationTimestamp, - [sortBy.provisioner]: (item: StorageClass) => item.provisioner, - [sortBy.reclaimPolicy]: (item: StorageClass) => item.reclaimPolicy, + [columnId.name]: (item: StorageClass) => item.getName(), + [columnId.age]: (item: StorageClass) => item.metadata.creationTimestamp, + [columnId.provisioner]: (item: StorageClass) => item.provisioner, + [columnId.reclaimPolicy]: (item: StorageClass) => item.reclaimPolicy, }} searchFilters={[ (item: StorageClass) => item.getSearchFields(), @@ -38,12 +41,12 @@ export class StorageClasses extends React.Component { ]} renderHeaderTitle="Storage Classes" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Provisioner", className: "provisioner", sortBy: sortBy.provisioner }, - { title: "Reclaim Policy", className: "reclaim-policy", sortBy: sortBy.reclaimPolicy }, - { title: "Default", className: "is-default" }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Provisioner", className: "provisioner", sortBy: columnId.provisioner, id: columnId.provisioner }, + { title: "Reclaim Policy", className: "reclaim-policy", sortBy: columnId.reclaimPolicy, id: columnId.reclaimPolicy }, + { title: "Default", className: "is-default", id: columnId.default }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(storageClass: StorageClass) => [ storageClass.getName(), diff --git a/src/renderer/components/+storage-volume-claims/volume-claims.tsx b/src/renderer/components/+storage-volume-claims/volume-claims.tsx index bb9a4a05a7..e93529b8d2 100644 --- a/src/renderer/components/+storage-volume-claims/volume-claims.tsx +++ b/src/renderer/components/+storage-volume-claims/volume-claims.tsx @@ -13,7 +13,7 @@ import { stopPropagation } from "../../utils"; import { storageClassApi } from "../../api/endpoints"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", pods = "pods", @@ -31,17 +31,19 @@ export class PersistentVolumeClaims extends React.Component { render() { return ( pvc.getName(), - [sortBy.namespace]: (pvc: PersistentVolumeClaim) => pvc.getNs(), - [sortBy.pods]: (pvc: PersistentVolumeClaim) => pvc.getPods(podsStore.items).map(pod => pod.getName()), - [sortBy.status]: (pvc: PersistentVolumeClaim) => pvc.getStatus(), - [sortBy.size]: (pvc: PersistentVolumeClaim) => unitsToBytes(pvc.getStorage()), - [sortBy.storageClass]: (pvc: PersistentVolumeClaim) => pvc.spec.storageClassName, - [sortBy.age]: (pvc: PersistentVolumeClaim) => pvc.metadata.creationTimestamp, + [columnId.name]: (pvc: PersistentVolumeClaim) => pvc.getName(), + [columnId.namespace]: (pvc: PersistentVolumeClaim) => pvc.getNs(), + [columnId.pods]: (pvc: PersistentVolumeClaim) => pvc.getPods(podsStore.items).map(pod => pod.getName()), + [columnId.status]: (pvc: PersistentVolumeClaim) => pvc.getStatus(), + [columnId.size]: (pvc: PersistentVolumeClaim) => unitsToBytes(pvc.getStorage()), + [columnId.storageClass]: (pvc: PersistentVolumeClaim) => pvc.spec.storageClassName, + [columnId.age]: (pvc: PersistentVolumeClaim) => pvc.metadata.creationTimestamp, }} searchFilters={[ (item: PersistentVolumeClaim) => item.getSearchFields(), @@ -49,14 +51,14 @@ export class PersistentVolumeClaims extends React.Component { ]} renderHeaderTitle="Persistent Volume Claims" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Storage class", className: "storageClass", sortBy: sortBy.storageClass }, - { title: "Size", className: "size", sortBy: sortBy.size }, - { title: "Pods", className: "pods", sortBy: sortBy.pods }, - { title: "Age", className: "age", sortBy: sortBy.age }, - { title: "Status", className: "status", sortBy: sortBy.status }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Storage class", className: "storageClass", sortBy: columnId.storageClass, id: columnId.storageClass }, + { title: "Size", className: "size", sortBy: columnId.size, id: columnId.size }, + { title: "Pods", className: "pods", sortBy: columnId.pods, id: columnId.pods }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, ]} renderTableContents={(pvc: PersistentVolumeClaim) => { const pods = pvc.getPods(podsStore.items); diff --git a/src/renderer/components/+storage-volumes/volumes.tsx b/src/renderer/components/+storage-volumes/volumes.tsx index 412093a7ad..6822e3d4f7 100644 --- a/src/renderer/components/+storage-volumes/volumes.tsx +++ b/src/renderer/components/+storage-volumes/volumes.tsx @@ -11,10 +11,11 @@ import { volumesStore } from "./volumes.store"; import { pvcApi, storageClassApi } from "../../api/endpoints"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", storageClass = "storage-class", capacity = "capacity", + claim = "claim", status = "status", age = "age", } @@ -27,14 +28,16 @@ export class PersistentVolumes extends React.Component { render() { return ( item.getName(), - [sortBy.storageClass]: (item: PersistentVolume) => item.spec.storageClassName, - [sortBy.capacity]: (item: PersistentVolume) => item.getCapacity(true), - [sortBy.status]: (item: PersistentVolume) => item.getStatus(), - [sortBy.age]: (item: PersistentVolume) => item.metadata.creationTimestamp, + [columnId.name]: (item: PersistentVolume) => item.getName(), + [columnId.storageClass]: (item: PersistentVolume) => item.spec.storageClassName, + [columnId.capacity]: (item: PersistentVolume) => item.getCapacity(true), + [columnId.status]: (item: PersistentVolume) => item.getStatus(), + [columnId.age]: (item: PersistentVolume) => item.metadata.creationTimestamp, }} searchFilters={[ (item: PersistentVolume) => item.getSearchFields(), @@ -42,13 +45,13 @@ export class PersistentVolumes extends React.Component { ]} renderHeaderTitle="Persistent Volumes" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Storage Class", className: "storageClass", sortBy: sortBy.storageClass }, - { title: "Capacity", className: "capacity", sortBy: sortBy.capacity }, - { title: "Claim", className: "claim" }, - { title: "Age", className: "age", sortBy: sortBy.age }, - { title: "Status", className: "status", sortBy: sortBy.status }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Storage Class", className: "storageClass", sortBy: columnId.storageClass, id: columnId.storageClass }, + { title: "Capacity", className: "capacity", sortBy: columnId.capacity, id: columnId.capacity }, + { title: "Claim", className: "claim", id: columnId.claim }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, ]} renderTableContents={(volume: PersistentVolume) => { const { claimRef, storageClassName } = volume.spec; diff --git a/src/renderer/components/+user-management-roles-bindings/role-bindings.store.ts b/src/renderer/components/+user-management-roles-bindings/role-bindings.store.ts index f293dea6f0..620fbd86ac 100644 --- a/src/renderer/components/+user-management-roles-bindings/role-bindings.store.ts +++ b/src/renderer/components/+user-management-roles-bindings/role-bindings.store.ts @@ -1,7 +1,7 @@ import difference from "lodash/difference"; import uniqBy from "lodash/uniqBy"; import { clusterRoleBindingApi, IRoleBindingSubject, RoleBinding, roleBindingApi } from "../../api/endpoints"; -import { KubeObjectStore } from "../../kube-object.store"; +import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store"; import { autobind } from "../../utils"; import { apiManager } from "../../api/api-manager"; @@ -9,8 +9,8 @@ import { apiManager } from "../../api/api-manager"; export class RoleBindingsStore extends KubeObjectStore { api = clusterRoleBindingApi; - subscribe() { - return super.subscribe([clusterRoleBindingApi, roleBindingApi]); + getSubscribeApis() { + return [clusterRoleBindingApi, roleBindingApi]; } protected sortItems(items: RoleBinding[]) { @@ -26,15 +26,13 @@ export class RoleBindingsStore extends KubeObjectStore { return clusterRoleBindingApi.get(params); } - protected loadItems(namespaces?: string[]) { - if (namespaces) { - return Promise.all( - namespaces.map(namespace => roleBindingApi.list({ namespace })) - ).then(items => items.flat()); - } else { - return Promise.all([clusterRoleBindingApi.list(), roleBindingApi.list()]) - .then(items => items.flat()); - } + protected async loadItems(params: KubeObjectStoreLoadingParams): Promise { + const items = await Promise.all([ + super.loadItems({ ...params, api: clusterRoleBindingApi }), + super.loadItems({ ...params, api: roleBindingApi }), + ]); + + return items.flat(); } protected async createItem(params: { name: string; namespace?: string }, data?: Partial) { diff --git a/src/renderer/components/+user-management-roles-bindings/role-bindings.tsx b/src/renderer/components/+user-management-roles-bindings/role-bindings.tsx index 3d64562047..f55e781e0e 100644 --- a/src/renderer/components/+user-management-roles-bindings/role-bindings.tsx +++ b/src/renderer/components/+user-management-roles-bindings/role-bindings.tsx @@ -10,7 +10,7 @@ import { KubeObjectListLayout } from "../kube-object"; import { AddRoleBindingDialog } from "./add-role-binding-dialog"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", bindings = "bindings", @@ -25,13 +25,15 @@ export class RoleBindings extends React.Component { render() { return ( binding.getName(), - [sortBy.namespace]: (binding: RoleBinding) => binding.getNs(), - [sortBy.bindings]: (binding: RoleBinding) => binding.getSubjectNames(), - [sortBy.age]: (binding: RoleBinding) => binding.metadata.creationTimestamp, + [columnId.name]: (binding: RoleBinding) => binding.getName(), + [columnId.namespace]: (binding: RoleBinding) => binding.getNs(), + [columnId.bindings]: (binding: RoleBinding) => binding.getSubjectNames(), + [columnId.age]: (binding: RoleBinding) => binding.metadata.creationTimestamp, }} searchFilters={[ (binding: RoleBinding) => binding.getSearchFields(), @@ -39,11 +41,11 @@ export class RoleBindings extends React.Component { ]} renderHeaderTitle="Role Bindings" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Bindings", className: "bindings", sortBy: sortBy.bindings }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Bindings", className: "bindings", sortBy: columnId.bindings, id: columnId.bindings }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(binding: RoleBinding) => [ binding.getName(), diff --git a/src/renderer/components/+user-management-roles/roles.store.ts b/src/renderer/components/+user-management-roles/roles.store.ts index 6af33deacb..82b0e66612 100644 --- a/src/renderer/components/+user-management-roles/roles.store.ts +++ b/src/renderer/components/+user-management-roles/roles.store.ts @@ -1,14 +1,14 @@ import { clusterRoleApi, Role, roleApi } from "../../api/endpoints"; import { autobind } from "../../utils"; -import { KubeObjectStore } from "../../kube-object.store"; +import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store"; import { apiManager } from "../../api/api-manager"; @autobind() export class RolesStore extends KubeObjectStore { api = clusterRoleApi; - subscribe() { - return super.subscribe([roleApi, clusterRoleApi]); + getSubscribeApis() { + return [roleApi, clusterRoleApi]; } protected sortItems(items: Role[]) { @@ -24,15 +24,13 @@ export class RolesStore extends KubeObjectStore { return clusterRoleApi.get(params); } - protected loadItems(namespaces?: string[]): Promise { - if (namespaces) { - return Promise.all( - namespaces.map(namespace => roleApi.list({ namespace })) - ).then(items => items.flat()); - } else { - return Promise.all([clusterRoleApi.list(), roleApi.list()]) - .then(items => items.flat()); - } + protected async loadItems(params: KubeObjectStoreLoadingParams): Promise { + const items = await Promise.all([ + super.loadItems({ ...params, api: clusterRoleApi }), + super.loadItems({ ...params, api: roleApi }), + ]); + + return items.flat(); } protected async createItem(params: { name: string; namespace?: string }, data?: Partial) { @@ -49,4 +47,4 @@ export const rolesStore = new RolesStore(); apiManager.registerStore(rolesStore, [ roleApi, clusterRoleApi, -]); \ No newline at end of file +]); diff --git a/src/renderer/components/+user-management-roles/roles.tsx b/src/renderer/components/+user-management-roles/roles.tsx index 21ad3bdf8a..a990cbfa7e 100644 --- a/src/renderer/components/+user-management-roles/roles.tsx +++ b/src/renderer/components/+user-management-roles/roles.tsx @@ -10,7 +10,7 @@ import { KubeObjectListLayout } from "../kube-object"; import { AddRoleDialog } from "./add-role-dialog"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", age = "age", @@ -25,22 +25,24 @@ export class Roles extends React.Component { return ( <> role.getName(), - [sortBy.namespace]: (role: Role) => role.getNs(), - [sortBy.age]: (role: Role) => role.metadata.creationTimestamp, + [columnId.name]: (role: Role) => role.getName(), + [columnId.namespace]: (role: Role) => role.getNs(), + [columnId.age]: (role: Role) => role.metadata.creationTimestamp, }} searchFilters={[ (role: Role) => role.getSearchFields(), ]} renderHeaderTitle="Roles" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(role: Role) => [ role.getName(), diff --git a/src/renderer/components/+user-management-service-accounts/index.ts b/src/renderer/components/+user-management-service-accounts/index.ts index fd45e28288..bd81292bf1 100644 --- a/src/renderer/components/+user-management-service-accounts/index.ts +++ b/src/renderer/components/+user-management-service-accounts/index.ts @@ -1,3 +1,3 @@ export * from "./service-accounts"; export * from "./service-accounts-details"; -export * from "./create-service-account-dialog"; \ No newline at end of file +export * from "./create-service-account-dialog"; diff --git a/src/renderer/components/+user-management-service-accounts/service-accounts.tsx b/src/renderer/components/+user-management-service-accounts/service-accounts.tsx index 37bed40ba9..4ea78904c0 100644 --- a/src/renderer/components/+user-management-service-accounts/service-accounts.tsx +++ b/src/renderer/components/+user-management-service-accounts/service-accounts.tsx @@ -15,7 +15,7 @@ import { CreateServiceAccountDialog } from "./create-service-account-dialog"; import { kubeObjectMenuRegistry } from "../../../extensions/registries/kube-object-menu-registry"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", age = "age", @@ -30,21 +30,23 @@ export class ServiceAccounts extends React.Component { return ( <> account.getName(), - [sortBy.namespace]: (account: ServiceAccount) => account.getNs(), - [sortBy.age]: (account: ServiceAccount) => account.metadata.creationTimestamp, + [columnId.name]: (account: ServiceAccount) => account.getName(), + [columnId.namespace]: (account: ServiceAccount) => account.getNs(), + [columnId.age]: (account: ServiceAccount) => account.metadata.creationTimestamp, }} searchFilters={[ (account: ServiceAccount) => account.getSearchFields(), ]} renderHeaderTitle="Service Accounts" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(account: ServiceAccount) => [ account.getName(), diff --git a/src/renderer/components/+user-management/index.ts b/src/renderer/components/+user-management/index.ts index 6f29869b9b..4ff825df97 100644 --- a/src/renderer/components/+user-management/index.ts +++ b/src/renderer/components/+user-management/index.ts @@ -1,2 +1,2 @@ export * from "./user-management"; -export * from "./user-management.route"; \ No newline at end of file +export * from "./user-management.route"; diff --git a/src/renderer/components/+workloads-cronjobs/cronjobs.tsx b/src/renderer/components/+workloads-cronjobs/cronjobs.tsx index 19d35e4b3a..08b84c0671 100644 --- a/src/renderer/components/+workloads-cronjobs/cronjobs.tsx +++ b/src/renderer/components/+workloads-cronjobs/cronjobs.tsx @@ -18,12 +18,13 @@ import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { ConfirmDialog } from "../confirm-dialog/confirm-dialog"; import { Notifications } from "../notifications/notifications"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", + schedule = "schedule", suspend = "suspend", active = "active", - lastSchedule = "schedule", + lastSchedule = "last-schedule", age = "age", } @@ -35,15 +36,17 @@ export class CronJobs extends React.Component { render() { return ( cronJob.getName(), - [sortBy.namespace]: (cronJob: CronJob) => cronJob.getNs(), - [sortBy.suspend]: (cronJob: CronJob) => cronJob.getSuspendFlag(), - [sortBy.active]: (cronJob: CronJob) => cronJobStore.getActiveJobsNum(cronJob), - [sortBy.lastSchedule]: (cronJob: CronJob) => cronJob.getLastScheduleTime(), - [sortBy.age]: (cronJob: CronJob) => cronJob.metadata.creationTimestamp, + [columnId.name]: (cronJob: CronJob) => cronJob.getName(), + [columnId.namespace]: (cronJob: CronJob) => cronJob.getNs(), + [columnId.suspend]: (cronJob: CronJob) => cronJob.getSuspendFlag(), + [columnId.active]: (cronJob: CronJob) => cronJobStore.getActiveJobsNum(cronJob), + [columnId.lastSchedule]: (cronJob: CronJob) => cronJob.getLastScheduleTime(), + [columnId.age]: (cronJob: CronJob) => cronJob.metadata.creationTimestamp, }} searchFilters={[ (cronJob: CronJob) => cronJob.getSearchFields(), @@ -51,14 +54,14 @@ export class CronJobs extends React.Component { ]} renderHeaderTitle="Cron Jobs" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Schedule", className: "schedule" }, - { title: "Suspend", className: "suspend", sortBy: sortBy.suspend }, - { title: "Active", className: "active", sortBy: sortBy.active }, - { title: "Last schedule", className: "last-schedule", sortBy: sortBy.lastSchedule }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Schedule", className: "schedule", id: columnId.schedule }, + { title: "Suspend", className: "suspend", sortBy: columnId.suspend, id: columnId.suspend }, + { title: "Active", className: "active", sortBy: columnId.active, id: columnId.active }, + { title: "Last schedule", className: "last-schedule", sortBy: columnId.lastSchedule, id: columnId.lastSchedule }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(cronJob: CronJob) => [ cronJob.getName(), diff --git a/src/renderer/components/+workloads-daemonsets/daemonsets.store.ts b/src/renderer/components/+workloads-daemonsets/daemonsets.store.ts index ad9713e96b..b8c8dee573 100644 --- a/src/renderer/components/+workloads-daemonsets/daemonsets.store.ts +++ b/src/renderer/components/+workloads-daemonsets/daemonsets.store.ts @@ -18,7 +18,7 @@ export class DaemonSetStore extends KubeObjectStore { } getChildPods(daemonSet: DaemonSet): Pod[] { - return podsStore.getPodsByOwner(daemonSet); + return podsStore.getPodsByOwnerId(daemonSet.getId()); } getStatuses(daemonSets?: DaemonSet[]) { diff --git a/src/renderer/components/+workloads-daemonsets/daemonsets.tsx b/src/renderer/components/+workloads-daemonsets/daemonsets.tsx index ff061f7877..e2d1e30e17 100644 --- a/src/renderer/components/+workloads-daemonsets/daemonsets.tsx +++ b/src/renderer/components/+workloads-daemonsets/daemonsets.tsx @@ -13,10 +13,11 @@ import { IDaemonSetsRouteParams } from "../+workloads"; import { Badge } from "../badge"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", pods = "pods", + labels = "labels", age = "age", } @@ -38,13 +39,15 @@ export class DaemonSets extends React.Component { render() { return ( daemonSet.getName(), - [sortBy.namespace]: (daemonSet: DaemonSet) => daemonSet.getNs(), - [sortBy.pods]: (daemonSet: DaemonSet) => this.getPodsLength(daemonSet), - [sortBy.age]: (daemonSet: DaemonSet) => daemonSet.metadata.creationTimestamp, + [columnId.name]: (daemonSet: DaemonSet) => daemonSet.getName(), + [columnId.namespace]: (daemonSet: DaemonSet) => daemonSet.getNs(), + [columnId.pods]: (daemonSet: DaemonSet) => this.getPodsLength(daemonSet), + [columnId.age]: (daemonSet: DaemonSet) => daemonSet.metadata.creationTimestamp, }} searchFilters={[ (daemonSet: DaemonSet) => daemonSet.getSearchFields(), @@ -52,12 +55,12 @@ export class DaemonSets extends React.Component { ]} renderHeaderTitle="Daemon Sets" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Pods", className: "pods", sortBy: sortBy.pods }, - { className: "warning" }, - { title: "Node Selector", className: "labels" }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Pods", className: "pods", sortBy: columnId.pods, id: columnId.pods }, + { className: "warning", showWithColumn: columnId.pods }, + { title: "Node Selector", className: "labels", id: columnId.labels }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(daemonSet: DaemonSet) => [ daemonSet.getName(), diff --git a/src/renderer/components/+workloads-deployments/deployments.tsx b/src/renderer/components/+workloads-deployments/deployments.tsx index b84cd7b340..0147c238b9 100644 --- a/src/renderer/components/+workloads-deployments/deployments.tsx +++ b/src/renderer/components/+workloads-deployments/deployments.tsx @@ -23,9 +23,10 @@ import { kubeObjectMenuRegistry } from "../../../extensions/registries/kube-obje import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { Notifications } from "../notifications"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", + pods = "pods", replicas = "replicas", age = "age", condition = "condition", @@ -55,14 +56,16 @@ export class Deployments extends React.Component { render() { return ( deployment.getName(), - [sortBy.namespace]: (deployment: Deployment) => deployment.getNs(), - [sortBy.replicas]: (deployment: Deployment) => deployment.getReplicas(), - [sortBy.age]: (deployment: Deployment) => deployment.metadata.creationTimestamp, - [sortBy.condition]: (deployment: Deployment) => deployment.getConditionsText(), + [columnId.name]: (deployment: Deployment) => deployment.getName(), + [columnId.namespace]: (deployment: Deployment) => deployment.getNs(), + [columnId.replicas]: (deployment: Deployment) => deployment.getReplicas(), + [columnId.age]: (deployment: Deployment) => deployment.metadata.creationTimestamp, + [columnId.condition]: (deployment: Deployment) => deployment.getConditionsText(), }} searchFilters={[ (deployment: Deployment) => deployment.getSearchFields(), @@ -70,13 +73,13 @@ export class Deployments extends React.Component { ]} renderHeaderTitle="Deployments" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Pods", className: "pods" }, - { title: "Replicas", className: "replicas", sortBy: sortBy.replicas }, - { title: "Age", className: "age", sortBy: sortBy.age }, - { title: "Conditions", className: "conditions", sortBy: sortBy.condition }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Pods", className: "pods", id: columnId.pods }, + { title: "Replicas", className: "replicas", sortBy: columnId.replicas, id: columnId.replicas }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Conditions", className: "conditions", sortBy: columnId.condition, id: columnId.condition }, ]} renderTableContents={(deployment: Deployment) => [ deployment.getName(), diff --git a/src/renderer/components/+workloads-jobs/job.store.ts b/src/renderer/components/+workloads-jobs/job.store.ts index 569c9efb13..41d514df8d 100644 --- a/src/renderer/components/+workloads-jobs/job.store.ts +++ b/src/renderer/components/+workloads-jobs/job.store.ts @@ -10,7 +10,7 @@ export class JobStore extends KubeObjectStore { api = jobApi; getChildPods(job: Job): Pod[] { - return podsStore.getPodsByOwner(job); + return podsStore.getPodsByOwnerId(job.getId()); } getJobsByOwner(cronJob: CronJob) { diff --git a/src/renderer/components/+workloads-jobs/jobs.tsx b/src/renderer/components/+workloads-jobs/jobs.tsx index 00c1ee0db5..6301c287d0 100644 --- a/src/renderer/components/+workloads-jobs/jobs.tsx +++ b/src/renderer/components/+workloads-jobs/jobs.tsx @@ -12,9 +12,10 @@ import { IJobsRouteParams } from "../+workloads"; import kebabCase from "lodash/kebabCase"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", + completions = "completions", conditions = "conditions", age = "age", } @@ -27,25 +28,27 @@ export class Jobs extends React.Component { render() { return ( job.getName(), - [sortBy.namespace]: (job: Job) => job.getNs(), - [sortBy.conditions]: (job: Job) => job.getCondition() != null ? job.getCondition().type : "", - [sortBy.age]: (job: Job) => job.metadata.creationTimestamp, + [columnId.name]: (job: Job) => job.getName(), + [columnId.namespace]: (job: Job) => job.getNs(), + [columnId.conditions]: (job: Job) => job.getCondition() != null ? job.getCondition().type : "", + [columnId.age]: (job: Job) => job.metadata.creationTimestamp, }} searchFilters={[ (job: Job) => job.getSearchFields(), ]} renderHeaderTitle="Jobs" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Completions", className: "completions" }, - { className: "warning" }, - { title: "Age", className: "age", sortBy: sortBy.age }, - { title: "Conditions", className: "conditions", sortBy: sortBy.conditions }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Completions", className: "completions", id: columnId.completions }, + { className: "warning", showWithColumn: columnId.completions }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Conditions", className: "conditions", sortBy: columnId.conditions, id: columnId.conditions }, ]} renderTableContents={(job: Job) => { const condition = job.getCondition(); diff --git a/src/renderer/components/+workloads-overview/overview-statuses.tsx b/src/renderer/components/+workloads-overview/overview-statuses.tsx index 78adecb6df..33e5aa37c5 100644 --- a/src/renderer/components/+workloads-overview/overview-statuses.tsx +++ b/src/renderer/components/+workloads-overview/overview-statuses.tsx @@ -27,7 +27,7 @@ export class OverviewStatuses extends React.Component { @autobind() renderWorkload(resource: KubeResource): React.ReactElement { const store = workloadStores[resource]; - const items = store.getAllByNs(namespaceStore.contextNs); + const items = store.getAllByNs(namespaceStore.getContextNamespaces()); return (
diff --git a/src/renderer/components/+workloads-overview/overview.tsx b/src/renderer/components/+workloads-overview/overview.tsx index 318ad53f77..50a25ef87c 100644 --- a/src/renderer/components/+workloads-overview/overview.tsx +++ b/src/renderer/components/+workloads-overview/overview.tsx @@ -1,8 +1,7 @@ import "./overview.scss"; import React from "react"; -import { observable, when } from "mobx"; -import { observer } from "mobx-react"; +import { disposeOnUnmount, observer } from "mobx-react"; import { OverviewStatuses } from "./overview-statuses"; import { RouteComponentProps } from "react-router"; import { IWorkloadsOverviewRouteParams } from "../+workloads"; @@ -15,83 +14,30 @@ import { replicaSetStore } from "../+workloads-replicasets/replicasets.store"; import { jobStore } from "../+workloads-jobs/job.store"; import { cronJobStore } from "../+workloads-cronjobs/cronjob.store"; import { Events } from "../+events"; -import { KubeObjectStore } from "../../kube-object.store"; import { isAllowedResource } from "../../../common/rbac"; +import { kubeWatchApi } from "../../api/kube-watch-api"; interface Props extends RouteComponentProps { } @observer export class WorkloadsOverview extends React.Component { - @observable isUnmounting = false; - - async componentDidMount() { - const stores: KubeObjectStore[] = []; - - if (isAllowedResource("pods")) { - stores.push(podsStore); - } - - if (isAllowedResource("deployments")) { - stores.push(deploymentStore); - } - - if (isAllowedResource("daemonsets")) { - stores.push(daemonSetStore); - } - - if (isAllowedResource("statefulsets")) { - stores.push(statefulSetStore); - } - - if (isAllowedResource("replicasets")) { - stores.push(replicaSetStore); - } - - if (isAllowedResource("jobs")) { - stores.push(jobStore); - } - - if (isAllowedResource("cronjobs")) { - stores.push(cronJobStore); - } - - if (isAllowedResource("events")) { - stores.push(eventStore); - } - - const unsubscribeList: Array<() => void> = []; - - for (const store of stores) { - await store.loadAll(); - unsubscribeList.push(store.subscribe()); - } - - await when(() => this.isUnmounting); - unsubscribeList.forEach(dispose => dispose()); - } - - componentWillUnmount() { - this.isUnmounting = true; - } - - get contents() { - return ( - <> - - { isAllowedResource("events") && } - - ); + componentDidMount() { + disposeOnUnmount(this, [ + kubeWatchApi.subscribeStores([ + podsStore, deploymentStore, daemonSetStore, statefulSetStore, replicaSetStore, + jobStore, cronJobStore, eventStore, + ], { + preload: true, + }), + ]); } render() { return (
- {this.contents} + + {isAllowedResource("events") && }
); } diff --git a/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx b/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx new file mode 100644 index 0000000000..dbde813e5a --- /dev/null +++ b/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx @@ -0,0 +1,59 @@ +/** + * @jest-environment jsdom + */ + +import React from "react"; +import "@testing-library/jest-dom/extend-expect"; +import { fireEvent, render } from "@testing-library/react"; +import { IToleration } from "../../../api/workload-kube-object"; +import { PodTolerations } from "../pod-tolerations"; + +const tolerations: IToleration[] =[ + { + key: "CriticalAddonsOnly", + operator: "Exist", + effect: "NoExecute", + tolerationSeconds: 3600 + }, + { + key: "node.kubernetes.io/not-ready", + operator: "NoExist", + effect: "NoSchedule", + tolerationSeconds: 7200 + }, +]; + +describe("", () => { + it("renders w/o errors", () => { + const { container } = render(); + + expect(container).toBeInstanceOf(HTMLElement); + }); + + it("shows all tolerations", () => { + const { container } = render(); + const rows = container.querySelectorAll(".TableRow"); + + expect(rows[0].querySelector(".key").textContent).toBe("CriticalAddonsOnly"); + expect(rows[0].querySelector(".operator").textContent).toBe("Exist"); + expect(rows[0].querySelector(".effect").textContent).toBe("NoExecute"); + expect(rows[0].querySelector(".seconds").textContent).toBe("3600"); + + expect(rows[1].querySelector(".key").textContent).toBe("node.kubernetes.io/not-ready"); + expect(rows[1].querySelector(".operator").textContent).toBe("NoExist"); + expect(rows[1].querySelector(".effect").textContent).toBe("NoSchedule"); + expect(rows[1].querySelector(".seconds").textContent).toBe("7200"); + }); + + it("sorts table properly", () => { + const { container, getByText } = render(); + const headCell = getByText("Key"); + + fireEvent.click(headCell); + fireEvent.click(headCell); + + const rows = container.querySelectorAll(".TableRow"); + + expect(rows[0].querySelector(".key").textContent).toBe("node.kubernetes.io/not-ready"); + }); +}); diff --git a/src/renderer/components/+workloads-pods/index.ts b/src/renderer/components/+workloads-pods/index.ts index f3181cb3a2..cc7782911c 100644 --- a/src/renderer/components/+workloads-pods/index.ts +++ b/src/renderer/components/+workloads-pods/index.ts @@ -1,2 +1,2 @@ export * from "./pods"; -export * from "./pod-details"; \ No newline at end of file +export * from "./pod-details"; diff --git a/src/renderer/components/+workloads-pods/pod-details-statuses.tsx b/src/renderer/components/+workloads-pods/pod-details-statuses.tsx index 5ce8465e72..1e0f765381 100644 --- a/src/renderer/components/+workloads-pods/pod-details-statuses.tsx +++ b/src/renderer/components/+workloads-pods/pod-details-statuses.tsx @@ -27,4 +27,4 @@ export class PodDetailsStatuses extends React.Component {
); } -} \ No newline at end of file +} diff --git a/src/renderer/components/+workloads-pods/pod-details-tolerations.scss b/src/renderer/components/+workloads-pods/pod-details-tolerations.scss index 0aa68fa1d6..1ac932cd9d 100644 --- a/src/renderer/components/+workloads-pods/pod-details-tolerations.scss +++ b/src/renderer/components/+workloads-pods/pod-details-tolerations.scss @@ -1,5 +1,23 @@ .PodDetailsTolerations { - .toleration { - margin-bottom: $margin; + grid-template-columns: auto; + + .PodTolerations { + margin-top: var(--margin); + } + + // Expanding value cell to cover 2 columns (whole Drawer width) + + > .name { + grid-row-start: 1; + grid-column-start: 1; + } + + > .value { + grid-row-start: 1; + grid-column-start: 1; + } + + .DrawerParamToggler > .params { + margin-left: var(--drawer-item-title-width); } } \ No newline at end of file diff --git a/src/renderer/components/+workloads-pods/pod-details-tolerations.tsx b/src/renderer/components/+workloads-pods/pod-details-tolerations.tsx index 8b67502e26..67bd5a07d0 100644 --- a/src/renderer/components/+workloads-pods/pod-details-tolerations.tsx +++ b/src/renderer/components/+workloads-pods/pod-details-tolerations.tsx @@ -1,10 +1,11 @@ import "./pod-details-tolerations.scss"; import React from "react"; -import { Pod, Deployment, DaemonSet, StatefulSet, ReplicaSet, Job } from "../../api/endpoints"; import { DrawerParamToggler, DrawerItem } from "../drawer"; +import { WorkloadKubeObject } from "../../api/workload-kube-object"; +import { PodTolerations } from "./pod-tolerations"; interface Props { - workload: Pod | Deployment | DaemonSet | StatefulSet | ReplicaSet | Job; + workload: WorkloadKubeObject; } export class PodDetailsTolerations extends React.Component { @@ -17,20 +18,7 @@ export class PodDetailsTolerations extends React.Component { return ( - { - tolerations.map((toleration, index) => { - const { key, operator, effect, tolerationSeconds } = toleration; - - return ( -
- {key} - {operator && {operator}} - {effect && {effect}} - {!!tolerationSeconds && {tolerationSeconds}} -
- ); - }) - } +
); diff --git a/src/renderer/components/+workloads-pods/pod-tolerations.scss b/src/renderer/components/+workloads-pods/pod-tolerations.scss new file mode 100644 index 0000000000..b840697685 --- /dev/null +++ b/src/renderer/components/+workloads-pods/pod-tolerations.scss @@ -0,0 +1,14 @@ +.PodTolerations { + .TableHead { + background-color: var(--drawerSubtitleBackground); + } + + .TableCell { + white-space: normal; + word-break: normal; + + &.key { + flex-grow: 3; + } + } +} \ No newline at end of file diff --git a/src/renderer/components/+workloads-pods/pod-tolerations.tsx b/src/renderer/components/+workloads-pods/pod-tolerations.tsx new file mode 100644 index 0000000000..e8d3d7d099 --- /dev/null +++ b/src/renderer/components/+workloads-pods/pod-tolerations.tsx @@ -0,0 +1,63 @@ +import "./pod-tolerations.scss"; +import React from "react"; +import uniqueId from "lodash/uniqueId"; + +import { IToleration } from "../../api/workload-kube-object"; +import { Table, TableCell, TableHead, TableRow } from "../table"; + +interface Props { + tolerations: IToleration[]; +} + +enum sortBy { + Key = "key", + Operator = "operator", + Effect = "effect", + Seconds = "seconds", +} + +const sortingCallbacks = { + [sortBy.Key]: (toleration: IToleration) => toleration.key, + [sortBy.Operator]: (toleration: IToleration) => toleration.operator, + [sortBy.Effect]: (toleration: IToleration) => toleration.effect, + [sortBy.Seconds]: (toleration: IToleration) => toleration.tolerationSeconds, +}; + +const getTableRow = (toleration: IToleration) => { + const { key, operator, effect, tolerationSeconds } = toleration; + + return ( + + {key} + {operator} + {effect} + {tolerationSeconds} + + ); +}; + +export function PodTolerations({ tolerations }: Props) { + return ( + + + Key + Operator + Effect + Seconds + + { + tolerations.map(getTableRow) + } +
+ ); +} diff --git a/src/renderer/components/+workloads-pods/pods.store.ts b/src/renderer/components/+workloads-pods/pods.store.ts index 9cd3c3b2f9..5a535cec66 100644 --- a/src/renderer/components/+workloads-pods/pods.store.ts +++ b/src/renderer/components/+workloads-pods/pods.store.ts @@ -3,8 +3,8 @@ import { action, observable } from "mobx"; import { KubeObjectStore } from "../../kube-object.store"; import { autobind, cpuUnitsToNumber, unitsToBytes } from "../../utils"; import { IPodMetrics, Pod, PodMetrics, podMetricsApi, podsApi } from "../../api/endpoints"; -import { WorkloadKubeObject } from "../../api/workload-kube-object"; import { apiManager } from "../../api/api-manager"; +import { WorkloadKubeObject } from "../../api/workload-kube-object"; @autobind() export class PodsStore extends KubeObjectStore { @@ -44,6 +44,12 @@ export class PodsStore extends KubeObjectStore { }); } + getPodsByOwnerId(workloadId: string): Pod[] { + return this.items.filter(pod => { + return pod.getOwnerRefs().find(owner => owner.uid === workloadId); + }); + } + getPodsByNode(node: string) { if (!this.isLoaded) return []; diff --git a/src/renderer/components/+workloads-pods/pods.tsx b/src/renderer/components/+workloads-pods/pods.tsx index 88981b968f..a59c9d79d2 100644 --- a/src/renderer/components/+workloads-pods/pods.tsx +++ b/src/renderer/components/+workloads-pods/pods.tsx @@ -19,8 +19,7 @@ import { lookupApiLink } from "../../api/kube-api"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { Badge } from "../badge"; - -enum sortBy { +enum columnId { name = "name", namespace = "namespace", containers = "containers", @@ -74,16 +73,18 @@ export class Pods extends React.Component { pod.getName(), - [sortBy.namespace]: (pod: Pod) => pod.getNs(), - [sortBy.containers]: (pod: Pod) => pod.getContainers().length, - [sortBy.restarts]: (pod: Pod) => pod.getRestartsCount(), - [sortBy.owners]: (pod: Pod) => pod.getOwnerRefs().map(ref => ref.kind), - [sortBy.qos]: (pod: Pod) => pod.getQosClass(), - [sortBy.node]: (pod: Pod) => pod.getNodeName(), - [sortBy.age]: (pod: Pod) => pod.metadata.creationTimestamp, - [sortBy.status]: (pod: Pod) => pod.getStatusMessage(), + [columnId.name]: (pod: Pod) => pod.getName(), + [columnId.namespace]: (pod: Pod) => pod.getNs(), + [columnId.containers]: (pod: Pod) => pod.getContainers().length, + [columnId.restarts]: (pod: Pod) => pod.getRestartsCount(), + [columnId.owners]: (pod: Pod) => pod.getOwnerRefs().map(ref => ref.kind), + [columnId.qos]: (pod: Pod) => pod.getQosClass(), + [columnId.node]: (pod: Pod) => pod.getNodeName(), + [columnId.age]: (pod: Pod) => pod.metadata.creationTimestamp, + [columnId.status]: (pod: Pod) => pod.getStatusMessage(), }} searchFilters={[ (pod: Pod) => pod.getSearchFields(), @@ -93,16 +94,16 @@ export class Pods extends React.Component { ]} renderHeaderTitle="Pods" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Containers", className: "containers", sortBy: sortBy.containers }, - { title: "Restarts", className: "restarts", sortBy: sortBy.restarts }, - { title: "Controlled By", className: "owners", sortBy: sortBy.owners }, - { title: "Node", className: "node", sortBy: sortBy.node }, - { title: "QoS", className: "qos", sortBy: sortBy.qos }, - { title: "Age", className: "age", sortBy: sortBy.age }, - { title: "Status", className: "status", sortBy: sortBy.status }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Containers", className: "containers", sortBy: columnId.containers, id: columnId.containers }, + { title: "Restarts", className: "restarts", sortBy: columnId.restarts, id: columnId.restarts }, + { title: "Controlled By", className: "owners", sortBy: columnId.owners, id: columnId.owners }, + { title: "Node", className: "node", sortBy: columnId.node, id: columnId.node }, + { title: "QoS", className: "qos", sortBy: columnId.qos, id: columnId.qos }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, ]} renderTableContents={(pod: Pod) => [ , diff --git a/src/renderer/components/+workloads-replicasets/replicasets.store.ts b/src/renderer/components/+workloads-replicasets/replicasets.store.ts index 337f9c0ae1..ca58006930 100644 --- a/src/renderer/components/+workloads-replicasets/replicasets.store.ts +++ b/src/renderer/components/+workloads-replicasets/replicasets.store.ts @@ -18,7 +18,7 @@ export class ReplicaSetStore extends KubeObjectStore { } getChildPods(replicaSet: ReplicaSet) { - return podsStore.getPodsByOwner(replicaSet); + return podsStore.getPodsByOwnerId(replicaSet.getId()); } getStatuses(replicaSets: ReplicaSet[]) { diff --git a/src/renderer/components/+workloads-replicasets/replicasets.tsx b/src/renderer/components/+workloads-replicasets/replicasets.tsx index 55f607e3c3..fa6ee5cef4 100644 --- a/src/renderer/components/+workloads-replicasets/replicasets.tsx +++ b/src/renderer/components/+workloads-replicasets/replicasets.tsx @@ -14,7 +14,7 @@ import { Icon } from "../icon/icon"; import { kubeObjectMenuRegistry } from "../../../extensions/registries/kube-object-menu-registry"; import { ReplicaSetScaleDialog } from "./replicaset-scale-dialog"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", desired = "desired", @@ -31,27 +31,29 @@ export class ReplicaSets extends React.Component { render() { return ( replicaSet.getName(), - [sortBy.namespace]: (replicaSet: ReplicaSet) => replicaSet.getNs(), - [sortBy.desired]: (replicaSet: ReplicaSet) => replicaSet.getDesired(), - [sortBy.current]: (replicaSet: ReplicaSet) => replicaSet.getCurrent(), - [sortBy.ready]: (replicaSet: ReplicaSet) => replicaSet.getReady(), - [sortBy.age]: (replicaSet: ReplicaSet) => replicaSet.metadata.creationTimestamp, + [columnId.name]: (replicaSet: ReplicaSet) => replicaSet.getName(), + [columnId.namespace]: (replicaSet: ReplicaSet) => replicaSet.getNs(), + [columnId.desired]: (replicaSet: ReplicaSet) => replicaSet.getDesired(), + [columnId.current]: (replicaSet: ReplicaSet) => replicaSet.getCurrent(), + [columnId.ready]: (replicaSet: ReplicaSet) => replicaSet.getReady(), + [columnId.age]: (replicaSet: ReplicaSet) => replicaSet.metadata.creationTimestamp, }} searchFilters={[ (replicaSet: ReplicaSet) => replicaSet.getSearchFields(), ]} renderHeaderTitle="Replica Sets" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Desired", className: "desired", sortBy: sortBy.desired }, - { title: "Current", className: "current", sortBy: sortBy.current }, - { title: "Ready", className: "ready", sortBy: sortBy.ready }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Desired", className: "desired", sortBy: columnId.desired, id: columnId.desired }, + { title: "Current", className: "current", sortBy: columnId.current, id: columnId.current }, + { title: "Ready", className: "ready", sortBy: columnId.ready, id: columnId.ready }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(replicaSet: ReplicaSet) => [ replicaSet.getName(), diff --git a/src/renderer/components/+workloads-statefulsets/index.ts b/src/renderer/components/+workloads-statefulsets/index.ts index 1cb72d701a..af942b604f 100644 --- a/src/renderer/components/+workloads-statefulsets/index.ts +++ b/src/renderer/components/+workloads-statefulsets/index.ts @@ -1,2 +1,2 @@ export * from "./statefulsets"; -export * from "./statefulset-details"; \ No newline at end of file +export * from "./statefulset-details"; diff --git a/src/renderer/components/+workloads-statefulsets/statefulset.store.ts b/src/renderer/components/+workloads-statefulsets/statefulset.store.ts index 12f1f663b9..6ee4bb5c28 100644 --- a/src/renderer/components/+workloads-statefulsets/statefulset.store.ts +++ b/src/renderer/components/+workloads-statefulsets/statefulset.store.ts @@ -17,7 +17,7 @@ export class StatefulSetStore extends KubeObjectStore { } getChildPods(statefulSet: StatefulSet) { - return podsStore.getPodsByOwner(statefulSet); + return podsStore.getPodsByOwnerId(statefulSet.getId()); } getStatuses(statefulSets: StatefulSet[]) { diff --git a/src/renderer/components/+workloads-statefulsets/statefulsets.tsx b/src/renderer/components/+workloads-statefulsets/statefulsets.tsx index 9e6011e156..7c91c9905c 100644 --- a/src/renderer/components/+workloads-statefulsets/statefulsets.tsx +++ b/src/renderer/components/+workloads-statefulsets/statefulsets.tsx @@ -17,9 +17,10 @@ import { MenuItem } from "../menu/menu"; import { Icon } from "../icon/icon"; import { kubeObjectMenuRegistry } from "../../../extensions/registries/kube-object-menu-registry"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", + pods = "pods", age = "age", replicas = "replicas", } @@ -38,25 +39,27 @@ export class StatefulSets extends React.Component { render() { return ( statefulSet.getName(), - [sortBy.namespace]: (statefulSet: StatefulSet) => statefulSet.getNs(), - [sortBy.age]: (statefulSet: StatefulSet) => statefulSet.metadata.creationTimestamp, - [sortBy.replicas]: (statefulSet: StatefulSet) => statefulSet.getReplicas(), + [columnId.name]: (statefulSet: StatefulSet) => statefulSet.getName(), + [columnId.namespace]: (statefulSet: StatefulSet) => statefulSet.getNs(), + [columnId.age]: (statefulSet: StatefulSet) => statefulSet.metadata.creationTimestamp, + [columnId.replicas]: (statefulSet: StatefulSet) => statefulSet.getReplicas(), }} searchFilters={[ (statefulSet: StatefulSet) => statefulSet.getSearchFields(), ]} renderHeaderTitle="Stateful Sets" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Pods", className: "pods" }, - { title: "Replicas", className: "replicas", sortBy: sortBy.replicas }, - { className: "warning" }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Pods", className: "pods", id: columnId.pods }, + { title: "Replicas", className: "replicas", sortBy: columnId.replicas, id: columnId.replicas }, + { className: "warning", showWithColumn: columnId.replicas }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(statefulSet: StatefulSet) => [ statefulSet.getName(), diff --git a/src/renderer/components/ace-editor/ace-editor.tsx b/src/renderer/components/ace-editor/ace-editor.tsx index 68ef92635a..2dceb48bba 100644 --- a/src/renderer/components/ace-editor/ace-editor.tsx +++ b/src/renderer/components/ace-editor/ace-editor.tsx @@ -154,4 +154,4 @@ export class AceEditor extends React.Component { ); } -} \ No newline at end of file +} diff --git a/src/renderer/components/ace-editor/index.ts b/src/renderer/components/ace-editor/index.ts index 7bfc7c01ea..173845abab 100644 --- a/src/renderer/components/ace-editor/index.ts +++ b/src/renderer/components/ace-editor/index.ts @@ -1 +1 @@ -export * from "./ace-editor"; \ No newline at end of file +export * from "./ace-editor"; diff --git a/src/renderer/components/add-remove-buttons/index.ts b/src/renderer/components/add-remove-buttons/index.ts index 825c59d7d2..fa2deb84ec 100644 --- a/src/renderer/components/add-remove-buttons/index.ts +++ b/src/renderer/components/add-remove-buttons/index.ts @@ -1 +1 @@ -export * from "./add-remove-buttons"; \ No newline at end of file +export * from "./add-remove-buttons"; diff --git a/src/renderer/components/animate/index.ts b/src/renderer/components/animate/index.ts index 080c5446c8..36d812de20 100644 --- a/src/renderer/components/animate/index.ts +++ b/src/renderer/components/animate/index.ts @@ -1 +1 @@ -export * from "./animate"; \ No newline at end of file +export * from "./animate"; diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 495182b2a9..767a905e4a 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { observer } from "mobx-react"; +import { disposeOnUnmount, observer } from "mobx-react"; import { Redirect, Route, Router, Switch } from "react-router"; import { history } from "../navigation"; import { Notifications } from "./notifications"; @@ -42,10 +42,10 @@ import { ClusterPageMenuRegistration, clusterPageMenuRegistry } from "../../exte import { TabLayout, TabLayoutRoute } from "./layout/tab-layout"; import { StatefulSetScaleDialog } from "./+workloads-statefulsets/statefulset-scale-dialog"; import { eventStore } from "./+events/event.store"; -import { computed, reaction } from "mobx"; +import { computed, reaction, observable } from "mobx"; import { nodesStore } from "./+nodes/nodes.store"; import { podsStore } from "./+workloads-pods/pods.store"; -import { sum } from "lodash"; +import { kubeWatchApi } from "../api/kube-watch-api"; import { ReplicaSetScaleDialog } from "./+workloads-replicasets/replicaset-scale-dialog"; @observer @@ -75,42 +75,26 @@ export class App extends React.Component { whatInput.ask(); // Start to monitor user input device } - async componentDidMount() { - const cluster = getHostedCluster(); - const promises: Promise[] = []; + componentDidMount() { + disposeOnUnmount(this, [ + kubeWatchApi.subscribeStores([podsStore, nodesStore, eventStore], { + preload: true, + }), - if (isAllowedResource("events") && isAllowedResource("pods")) { - promises.push(eventStore.loadAll()); - promises.push(podsStore.loadAll()); - } + reaction(() => this.warningsTotal, (count: number) => { + broadcastMessage(`cluster-warning-event-count:${getHostedCluster().id}`, count); + }), - if (isAllowedResource("nodes")) { - promises.push(nodesStore.loadAll()); - } - await Promise.all(promises); - - if (eventStore.isLoaded && podsStore.isLoaded) { - eventStore.subscribe(); - podsStore.subscribe(); - } - - if (nodesStore.isLoaded) { - nodesStore.subscribe(); - } - - reaction(() => this.warningsCount, (count) => { - broadcastMessage(`cluster-warning-event-count:${cluster.id}`, count); - }); + reaction(() => clusterPageMenuRegistry.getRootItems(), (rootItems) => { + this.generateExtensionTabLayoutRoutes(rootItems); + }, { + fireImmediately: true + }) + ]); } - @computed - get warningsCount() { - let warnings = sum(nodesStore.items - .map(node => node.getWarningConditions().length)); - - warnings = warnings + eventStore.getWarnings().length; - - return warnings; + @computed get warningsTotal(): number { + return nodesStore.getWarningsCount() + eventStore.getWarningsCount(); } get startURL() { @@ -161,6 +145,38 @@ export class App extends React.Component { }); } + @observable extensionRoutes: Map = new Map(); + + generateExtensionTabLayoutRoutes(rootItems: ClusterPageMenuRegistration[]) { + rootItems.forEach((menu, index) => { + let route = this.extensionRoutes.get(menu); + + if (!route) { + const tabRoutes = this.getTabLayoutRoutes(menu); + + if (tabRoutes.length > 0) { + const pageComponent = () => ; + + route = tab.routePath)} />; + this.extensionRoutes.set(menu, route); + } else { + const page = clusterPageRegistry.getByPageTarget(menu.target); + + if (page) { + route = ; + this.extensionRoutes.set(menu, route); + } + } + } + }); + + for (const menu of this.extensionRoutes.keys()) { + if (!rootItems.includes(menu)) { + this.extensionRoutes.delete(menu); + } + } + } + renderExtensionRoutes() { return clusterPageRegistry.getItems().map((page, index) => { const menu = clusterPageMenuRegistry.getByPage(page); diff --git a/src/renderer/components/chart/background-block.plugin.ts b/src/renderer/components/chart/background-block.plugin.ts index 1d39f71aed..ff4816c4dd 100644 --- a/src/renderer/components/chart/background-block.plugin.ts +++ b/src/renderer/components/chart/background-block.plugin.ts @@ -39,4 +39,4 @@ export const BackgroundBlock = { ctx.stroke(); ctx.restore(); } -}; \ No newline at end of file +}; diff --git a/src/renderer/components/chart/bar-chart.tsx b/src/renderer/components/chart/bar-chart.tsx index 69b65b10c9..4f80258703 100644 --- a/src/renderer/components/chart/bar-chart.tsx +++ b/src/renderer/components/chart/bar-chart.tsx @@ -220,4 +220,4 @@ export const cpuOptions: ChartOptions = { } } } -}; \ No newline at end of file +}; diff --git a/src/renderer/components/chart/chart.tsx b/src/renderer/components/chart/chart.tsx index b7e621be22..e42a308848 100644 --- a/src/renderer/components/chart/chart.tsx +++ b/src/renderer/components/chart/chart.tsx @@ -213,4 +213,4 @@ export class Chart extends React.Component { ); } -} \ No newline at end of file +} diff --git a/src/renderer/components/chart/index.ts b/src/renderer/components/chart/index.ts index a9db66c298..d75ddf7a2f 100644 --- a/src/renderer/components/chart/index.ts +++ b/src/renderer/components/chart/index.ts @@ -1,3 +1,3 @@ export * from "./chart"; export * from "./pie-chart"; -export * from "./bar-chart"; \ No newline at end of file +export * from "./bar-chart"; diff --git a/src/renderer/components/chart/pie-chart.tsx b/src/renderer/components/chart/pie-chart.tsx index 1c629ab505..939d6bb612 100644 --- a/src/renderer/components/chart/pie-chart.tsx +++ b/src/renderer/components/chart/pie-chart.tsx @@ -64,4 +64,4 @@ export class PieChart extends React.Component { ChartJS.Tooltip.positioners.cursor = function (elements: any, position: { x: number; y: number }) { return position; -}; \ No newline at end of file +}; diff --git a/src/renderer/components/chart/useRealTimeMetrics.ts b/src/renderer/components/chart/useRealTimeMetrics.ts index 69e4e3da7f..b01629e8e9 100644 --- a/src/renderer/components/chart/useRealTimeMetrics.ts +++ b/src/renderer/components/chart/useRealTimeMetrics.ts @@ -42,4 +42,4 @@ export function useRealTimeMetrics(metrics: IMetricValues, chartData: IChartData } return data; -} \ No newline at end of file +} diff --git a/src/renderer/components/chart/zebra-stripes.plugin.ts b/src/renderer/components/chart/zebra-stripes.plugin.ts index f934f88fb2..3a85f8d0a2 100644 --- a/src/renderer/components/chart/zebra-stripes.plugin.ts +++ b/src/renderer/components/chart/zebra-stripes.plugin.ts @@ -95,4 +95,4 @@ export const ZebraStripes = { cover.style.backgroundPositionX = `${-step * minutes}px`; } } -}; \ No newline at end of file +}; diff --git a/src/renderer/components/checkbox/checkbox.tsx b/src/renderer/components/checkbox/checkbox.tsx index 0831e6122f..8d452a1198 100644 --- a/src/renderer/components/checkbox/checkbox.tsx +++ b/src/renderer/components/checkbox/checkbox.tsx @@ -30,7 +30,7 @@ export class Checkbox extends React.PureComponent { render() { const { label, inline, className, value, theme, children, ...inputProps } = this.props; - const componentClass = cssNames("Checkbox flex", className, { + const componentClass = cssNames("Checkbox flex align-center", className, { inline, checked: value, disabled: this.props.disabled, @@ -50,4 +50,4 @@ export class Checkbox extends React.PureComponent { ); } -} \ No newline at end of file +} diff --git a/src/renderer/components/checkbox/index.ts b/src/renderer/components/checkbox/index.ts index 7af8873e06..057f167821 100644 --- a/src/renderer/components/checkbox/index.ts +++ b/src/renderer/components/checkbox/index.ts @@ -1 +1 @@ -export * from "./checkbox"; \ No newline at end of file +export * from "./checkbox"; diff --git a/src/renderer/components/cluster-icon/index.ts b/src/renderer/components/cluster-icon/index.ts index 4e1858939f..7879490b85 100644 --- a/src/renderer/components/cluster-icon/index.ts +++ b/src/renderer/components/cluster-icon/index.ts @@ -1 +1 @@ -export * from "./cluster-icon"; \ No newline at end of file +export * from "./cluster-icon"; diff --git a/src/renderer/components/confirm-dialog/index.ts b/src/renderer/components/confirm-dialog/index.ts index dfcd83ded3..4627fd6882 100644 --- a/src/renderer/components/confirm-dialog/index.ts +++ b/src/renderer/components/confirm-dialog/index.ts @@ -1 +1 @@ -export * from "./confirm-dialog"; \ No newline at end of file +export * from "./confirm-dialog"; diff --git a/src/renderer/components/dock/__test__/dock-tabs.test.tsx b/src/renderer/components/dock/__test__/dock-tabs.test.tsx index bcf6b94a2b..f893e06540 100644 --- a/src/renderer/components/dock/__test__/dock-tabs.test.tsx +++ b/src/renderer/components/dock/__test__/dock-tabs.test.tsx @@ -4,8 +4,6 @@ import "@testing-library/jest-dom/extend-expect"; import { DockTabs } from "../dock-tabs"; import { dockStore, IDockTab, TabKind } from "../dock.store"; -import { createResourceTab } from "../create-resource.store"; -import { createTerminalTab } from "../terminal.store"; import { observable } from "mobx"; const onChangeTab = jest.fn(); @@ -25,11 +23,19 @@ const getTabKinds = () => dockStore.tabs.map(tab => tab.kind); describe("", () => { beforeEach(() => { - createTerminalTab(); - createResourceTab(); - createTerminalTab(); - createResourceTab(); - createTerminalTab(); + const terminalTab: IDockTab = { id: "terminal1", kind: TabKind.TERMINAL, title: "Terminal" }; + const createResourceTab: IDockTab = { id: "create", kind: TabKind.CREATE_RESOURCE, title: "Create resource" }; + const editResourceTab: IDockTab = { id: "edit", kind: TabKind.EDIT_RESOURCE, title: "Edit resource" }; + const installChartTab: IDockTab = { id: "install", kind: TabKind.INSTALL_CHART, title: "Install chart" }; + const logsTab: IDockTab = { id: "logs", kind: TabKind.POD_LOGS, title: "Logs" }; + + dockStore.tabs.push( + terminalTab, + createResourceTab, + editResourceTab, + installChartTab, + logsTab + ); }); afterEach(() => { @@ -72,9 +78,9 @@ describe("", () => { expect(getTabKinds()).toEqual([ TabKind.TERMINAL, TabKind.CREATE_RESOURCE, - TabKind.TERMINAL, - TabKind.CREATE_RESOURCE, - TabKind.TERMINAL + TabKind.EDIT_RESOURCE, + TabKind.INSTALL_CHART, + TabKind.POD_LOGS ]); }); @@ -90,7 +96,7 @@ describe("", () => { const tabs = container.querySelectorAll(".Tab"); expect(tabs.length).toBe(1); - expect(getTabKinds()).toEqual([TabKind.TERMINAL]); + expect(getTabKinds()).toEqual([TabKind.EDIT_RESOURCE]); }); it("closes all tabs", () => { @@ -123,7 +129,7 @@ describe("", () => { TabKind.TERMINAL, TabKind.TERMINAL, TabKind.CREATE_RESOURCE, - TabKind.TERMINAL + TabKind.EDIT_RESOURCE ]); }); diff --git a/src/renderer/components/dock/__test__/log-resource-selector.test.tsx b/src/renderer/components/dock/__test__/log-resource-selector.test.tsx new file mode 100644 index 0000000000..22d97b7216 --- /dev/null +++ b/src/renderer/components/dock/__test__/log-resource-selector.test.tsx @@ -0,0 +1,103 @@ +/** + * @jest-environment jsdom + */ + +import React from "react"; +import "@testing-library/jest-dom/extend-expect"; +import { render } from "@testing-library/react"; +import selectEvent from "react-select-event"; + +import { Pod } from "../../../api/endpoints"; +import { LogResourceSelector } from "../log-resource-selector"; +import { LogTabData } from "../log-tab.store"; +import { dockerPod, deploymentPod1 } from "./pod.mock"; + +const getComponent = (tabData: LogTabData) => { + return ( + + ); +}; + +const getOnePodTabData = (): LogTabData => { + const selectedPod = new Pod(dockerPod); + + return { + pods: [] as Pod[], + selectedPod, + selectedContainer: selectedPod.getContainers()[0], + }; +}; + +const getFewPodsTabData = (): LogTabData => { + const selectedPod = new Pod(deploymentPod1); + const anotherPod = new Pod(dockerPod); + + return { + pods: [anotherPod], + selectedPod, + selectedContainer: selectedPod.getContainers()[0], + }; +}; + +describe("", () => { + it("renders w/o errors", () => { + const tabData = getOnePodTabData(); + const { container } = render(getComponent(tabData)); + + expect(container).toBeInstanceOf(HTMLElement); + }); + + it("renders proper namespace", () => { + const tabData = getOnePodTabData(); + const { getByTestId } = render(getComponent(tabData)); + const ns = getByTestId("namespace-badge"); + + expect(ns).toHaveTextContent("default"); + }); + + it("renders proper selected items within dropdowns", () => { + const tabData = getOnePodTabData(); + const { getByText } = render(getComponent(tabData)); + + expect(getByText("dockerExporter")).toBeInTheDocument(); + expect(getByText("docker-exporter")).toBeInTheDocument(); + }); + + it("renders sibling pods in dropdown", () => { + const tabData = getFewPodsTabData(); + const { container, getByText } = render(getComponent(tabData)); + const podSelector: HTMLElement = container.querySelector(".pod-selector"); + + selectEvent.openMenu(podSelector); + + expect(getByText("dockerExporter")).toBeInTheDocument(); + expect(getByText("deploymentPod1")).toBeInTheDocument(); + }); + + it("renders sibling containers in dropdown", () => { + const tabData = getFewPodsTabData(); + const { getByText, container } = render(getComponent(tabData)); + const containerSelector: HTMLElement = container.querySelector(".container-selector"); + + selectEvent.openMenu(containerSelector); + + expect(getByText("node-exporter-1")).toBeInTheDocument(); + expect(getByText("init-node-exporter")).toBeInTheDocument(); + expect(getByText("init-node-exporter-1")).toBeInTheDocument(); + }); + + it("renders pod owner as dropdown title", () => { + const tabData = getFewPodsTabData(); + const { getByText, container } = render(getComponent(tabData)); + const podSelector: HTMLElement = container.querySelector(".pod-selector"); + + selectEvent.openMenu(podSelector); + + expect(getByText("super-deployment")).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/dock/__test__/log-tab.store.test.ts b/src/renderer/components/dock/__test__/log-tab.store.test.ts new file mode 100644 index 0000000000..79b93af623 --- /dev/null +++ b/src/renderer/components/dock/__test__/log-tab.store.test.ts @@ -0,0 +1,113 @@ +/** + * @jest-environment jsdom + */ + +import { podsStore } from "../../+workloads-pods/pods.store"; +import { Pod } from "../../../api/endpoints"; +import { dockStore } from "../dock.store"; +import { logTabStore } from "../log-tab.store"; +import { deploymentPod1, deploymentPod2, deploymentPod3, dockerPod } from "./pod.mock"; + + +podsStore.items.push(new Pod(dockerPod)); +podsStore.items.push(new Pod(deploymentPod1)); +podsStore.items.push(new Pod(deploymentPod2)); + +describe("log tab store", () => { + afterEach(() => { + logTabStore.reset(); + dockStore.reset(); + }); + + it("creates log tab without sibling pods", () => { + const selectedPod = new Pod(dockerPod); + const selectedContainer = selectedPod.getAllContainers()[0]; + + logTabStore.createPodTab({ + selectedPod, + selectedContainer + }); + + expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ + pods: [selectedPod], + selectedPod, + selectedContainer, + showTimestamps: false, + previous: false + }); + }); + + it("creates log tab with sibling pods", () => { + const selectedPod = new Pod(deploymentPod1); + const siblingPod = new Pod(deploymentPod2); + const selectedContainer = selectedPod.getInitContainers()[0]; + + logTabStore.createPodTab({ + selectedPod, + selectedContainer + }); + + expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ + pods: [selectedPod, siblingPod], + selectedPod, + selectedContainer, + showTimestamps: false, + previous: false + }); + }); + + it("removes item from pods list if pod deleted from store", () => { + const selectedPod = new Pod(deploymentPod1); + const selectedContainer = selectedPod.getInitContainers()[0]; + + logTabStore.createPodTab({ + selectedPod, + selectedContainer + }); + + podsStore.items.pop(); + + expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ + pods: [selectedPod], + selectedPod, + selectedContainer, + showTimestamps: false, + previous: false + }); + }); + + it("adds item into pods list if new sibling pod added to store", () => { + const selectedPod = new Pod(deploymentPod1); + const selectedContainer = selectedPod.getInitContainers()[0]; + + logTabStore.createPodTab({ + selectedPod, + selectedContainer + }); + + podsStore.items.push(new Pod(deploymentPod3)); + + expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ + pods: [selectedPod, deploymentPod3], + selectedPod, + selectedContainer, + showTimestamps: false, + previous: false + }); + }); + + it("closes tab if no pods left in store", () => { + const selectedPod = new Pod(deploymentPod1); + const selectedContainer = selectedPod.getInitContainers()[0]; + + logTabStore.createPodTab({ + selectedPod, + selectedContainer + }); + + podsStore.items.clear(); + + expect(logTabStore.getData(dockStore.selectedTabId)).toBeUndefined(); + expect(dockStore.getTabById(dockStore.selectedTabId)).toBeUndefined(); + }); +}); diff --git a/src/renderer/components/dock/__test__/pod.mock.ts b/src/renderer/components/dock/__test__/pod.mock.ts new file mode 100644 index 0000000000..acb4704395 --- /dev/null +++ b/src/renderer/components/dock/__test__/pod.mock.ts @@ -0,0 +1,203 @@ +export const dockerPod = { + apiVersion: "v1", + kind: "dummy", + metadata: { + uid: "dockerExporter", + name: "dockerExporter", + creationTimestamp: "dummy", + resourceVersion: "dummy", + namespace: "default" + }, + spec: { + initContainers: [] as any, + containers: [ + { + name: "docker-exporter", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + } + ], + serviceAccountName: "dummy", + serviceAccount: "dummy", + }, + status: { + phase: "Running", + conditions: [{ + type: "Running", + status: "Running", + lastProbeTime: 1, + lastTransitionTime: "Some time", + }], + hostIP: "dummy", + podIP: "dummy", + startTime: "dummy", + } +}; + +export const deploymentPod1 = { + apiVersion: "v1", + kind: "dummy", + metadata: { + uid: "deploymentPod1", + name: "deploymentPod1", + creationTimestamp: "dummy", + resourceVersion: "dummy", + namespace: "default", + ownerReferences: [{ + apiVersion: "v1", + kind: "Deployment", + name: "super-deployment", + uid: "uuid", + controller: true, + blockOwnerDeletion: true, + }] + }, + spec: { + initContainers: [ + { + name: "init-node-exporter", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + }, + { + name: "init-node-exporter-1", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + } + ], + containers: [ + { + name: "node-exporter", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + }, + { + name: "node-exporter-1", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + } + ], + serviceAccountName: "dummy", + serviceAccount: "dummy", + }, + status: { + phase: "Running", + conditions: [{ + type: "Running", + status: "Running", + lastProbeTime: 1, + lastTransitionTime: "Some time", + }], + hostIP: "dummy", + podIP: "dummy", + startTime: "dummy", + } +}; + +export const deploymentPod2 = { + apiVersion: "v1", + kind: "dummy", + metadata: { + uid: "deploymentPod2", + name: "deploymentPod2", + creationTimestamp: "dummy", + resourceVersion: "dummy", + namespace: "default", + ownerReferences: [{ + apiVersion: "v1", + kind: "Deployment", + name: "super-deployment", + uid: "uuid", + controller: true, + blockOwnerDeletion: true, + }] + }, + spec: { + initContainers: [ + { + name: "init-node-exporter", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + }, + { + name: "init-node-exporter-1", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + } + ], + containers: [ + { + name: "node-exporter", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + }, + { + name: "node-exporter-1", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + } + ], + serviceAccountName: "dummy", + serviceAccount: "dummy", + }, + status: { + phase: "Running", + conditions: [{ + type: "Running", + status: "Running", + lastProbeTime: 1, + lastTransitionTime: "Some time", + }], + hostIP: "dummy", + podIP: "dummy", + startTime: "dummy", + } +}; + +export const deploymentPod3 = { + apiVersion: "v1", + kind: "dummy", + metadata: { + uid: "deploymentPod3", + name: "deploymentPod3", + creationTimestamp: "dummy", + resourceVersion: "dummy", + namespace: "default", + ownerReferences: [{ + apiVersion: "v1", + kind: "Deployment", + name: "super-deployment", + uid: "uuid", + controller: true, + blockOwnerDeletion: true, + }] + }, + spec: { + containers: [ + { + name: "node-exporter", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + }, + { + name: "node-exporter-1", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + } + ], + serviceAccountName: "dummy", + serviceAccount: "dummy", + }, + status: { + phase: "Running", + conditions: [{ + type: "Running", + status: "Running", + lastProbeTime: 1, + lastTransitionTime: "Some time", + }], + hostIP: "dummy", + podIP: "dummy", + startTime: "dummy", + } +}; diff --git a/src/renderer/components/dock/dock-tabs.tsx b/src/renderer/components/dock/dock-tabs.tsx index 54451ddd89..d0a3c3d125 100644 --- a/src/renderer/components/dock/dock-tabs.tsx +++ b/src/renderer/components/dock/dock-tabs.tsx @@ -7,7 +7,7 @@ import { DockTab } from "./dock-tab"; import { IDockTab } from "./dock.store"; import { isEditResourceTab } from "./edit-resource.store"; import { isInstallChartTab } from "./install-chart.store"; -import { isPodLogsTab } from "./pod-logs.store"; +import { isLogsTab } from "./log-tab.store"; import { TerminalTab } from "./terminal-tab"; import { isTerminalTab } from "./terminal.store"; import { isUpgradeChartTab } from "./upgrade-chart.store"; @@ -33,7 +33,7 @@ export const DockTabs = ({ tabs, autoFocus, selectedTab, onChangeTab }: Props) = return } />; } - if (isPodLogsTab(tab)) { + if (isLogsTab(tab)) { return ; } }; @@ -48,4 +48,4 @@ export const DockTabs = ({ tabs, autoFocus, selectedTab, onChangeTab }: Props) = {tabs.map(tab => {renderTab(tab)})} ); -}; \ No newline at end of file +}; diff --git a/src/renderer/components/dock/dock.store.ts b/src/renderer/components/dock/dock.store.ts index 91d72d98d9..423367093e 100644 --- a/src/renderer/components/dock/dock.store.ts +++ b/src/renderer/components/dock/dock.store.ts @@ -208,6 +208,12 @@ export class DockStore { this.closeTabs(tabs); } + renameTab(tabId: TabId, title: string) { + const tab = this.getTabById(tabId); + + tab.title = title; + } + @action selectTab(tabId: TabId) { this.selectedTabId = this.getTabById(tabId)?.id ?? null; diff --git a/src/renderer/components/dock/dock.tsx b/src/renderer/components/dock/dock.tsx index f02502eab7..45e294b3c6 100644 --- a/src/renderer/components/dock/dock.tsx +++ b/src/renderer/components/dock/dock.tsx @@ -16,8 +16,8 @@ import { EditResource } from "./edit-resource"; import { isEditResourceTab } from "./edit-resource.store"; import { InstallChart } from "./install-chart"; import { isInstallChartTab } from "./install-chart.store"; -import { PodLogs } from "./pod-logs"; -import { isPodLogsTab } from "./pod-logs.store"; +import { Logs } from "./logs"; +import { isLogsTab } from "./log-tab.store"; import { TerminalWindow } from "./terminal-window"; import { createTerminalTab, isTerminalTab } from "./terminal.store"; import { UpgradeChart } from "./upgrade-chart"; @@ -64,7 +64,7 @@ export class Dock extends React.Component { {isInstallChartTab(tab) && } {isUpgradeChartTab(tab) && } {isTerminalTab(tab) && } - {isPodLogsTab(tab) && } + {isLogsTab(tab) && } ); } diff --git a/src/renderer/components/dock/info-panel.scss b/src/renderer/components/dock/info-panel.scss index 23dcc52243..cf9b3268b2 100644 --- a/src/renderer/components/dock/info-panel.scss +++ b/src/renderer/components/dock/info-panel.scss @@ -1,13 +1,16 @@ .InfoPanel { @include hidden-scrollbar; - background: $dockInfoBackground; - border-bottom: 1px solid $dockInfoBorderColor; - padding: $padding $padding * 2; + background: var(--dockInfoBackground); + padding: var(--padding) calc(var(--padding) * 2); flex-shrink: 0; .Spinner { - margin-right: $padding; + margin-right: var(--padding); + } + + .Badge { + background-color: var(--dockBadgeBackground); } > .controls { @@ -16,8 +19,8 @@ &:not(:empty) + .info { min-height: 25px; - padding-left: $padding; - padding-right: $padding; + padding-left: var(--padding); + padding-right: var(--padding); } } } \ No newline at end of file diff --git a/src/renderer/components/dock/log-controls.scss b/src/renderer/components/dock/log-controls.scss new file mode 100644 index 0000000000..e446cca235 --- /dev/null +++ b/src/renderer/components/dock/log-controls.scss @@ -0,0 +1,6 @@ +.LogControls { + @include hidden-scrollbar; + + background: $dockInfoBackground; + padding: $padding $padding * 2; +} \ No newline at end of file diff --git a/src/renderer/components/dock/log-controls.tsx b/src/renderer/components/dock/log-controls.tsx new file mode 100644 index 0000000000..8400aef584 --- /dev/null +++ b/src/renderer/components/dock/log-controls.tsx @@ -0,0 +1,74 @@ +import "./log-controls.scss"; + +import React from "react"; +import { observer } from "mobx-react"; + +import { Pod } from "../../api/endpoints"; +import { cssNames, saveFileDialog } from "../../utils"; +import { logStore } from "./log.store"; +import { Checkbox } from "../checkbox"; +import { Icon } from "../icon"; +import { LogTabData } from "./log-tab.store"; + +interface Props { + tabData: LogTabData + logs: string[] + save: (data: Partial) => void + reload: () => void +} + +export const LogControls = observer((props: Props) => { + const { tabData, save, reload, logs } = props; + const { showTimestamps, previous } = tabData; + const since = logs.length ? logStore.getTimestamps(logs[0]) : null; + const pod = new Pod(tabData.selectedPod); + + const toggleTimestamps = () => { + save({ showTimestamps: !showTimestamps }); + }; + + const togglePrevious = () => { + save({ previous: !previous }); + reload(); + }; + + const downloadLogs = () => { + const fileName = pod.getName(); + const logsToDownload = showTimestamps ? logs : logStore.logsWithoutTimestamps; + + saveFileDialog(`${fileName}.log`, logsToDownload.join("\n"), "text/plain"); + }; + + return ( +
+
+ {since && ( + + Logs from{" "} + {new Date(since[0]).toLocaleString()} + + )} +
+
+ + + +
+
+ ); +}); diff --git a/src/renderer/components/dock/pod-log-list.scss b/src/renderer/components/dock/log-list.scss similarity index 99% rename from src/renderer/components/dock/pod-log-list.scss rename to src/renderer/components/dock/log-list.scss index 9b923b520b..8a39dcf925 100644 --- a/src/renderer/components/dock/pod-log-list.scss +++ b/src/renderer/components/dock/log-list.scss @@ -1,4 +1,4 @@ -.PodLogList { +.LogList { --overlay-bg: #8cc474b8; --overlay-active-bg: orange; diff --git a/src/renderer/components/dock/pod-log-list.tsx b/src/renderer/components/dock/log-list.tsx similarity index 92% rename from src/renderer/components/dock/pod-log-list.tsx rename to src/renderer/components/dock/log-list.tsx index c876d0c362..3b66f42d86 100644 --- a/src/renderer/components/dock/pod-log-list.tsx +++ b/src/renderer/components/dock/log-list.tsx @@ -1,4 +1,4 @@ -import "./pod-log-list.scss"; +import "./log-list.scss"; import React from "react"; import AnsiUp from "ansi_up"; @@ -14,7 +14,8 @@ import { Button } from "../button"; import { Icon } from "../icon"; import { Spinner } from "../spinner"; import { VirtualList } from "../virtual-list"; -import { podLogsStore } from "./pod-logs.store"; +import { logStore } from "./log.store"; +import { logTabStore } from "./log-tab.store"; interface Props { logs: string[] @@ -26,7 +27,7 @@ interface Props { const colorConverter = new AnsiUp(); @observer -export class PodLogList extends React.Component { +export class LogList extends React.Component { @observable isJumpButtonVisible = false; @observable isLastLineVisible = true; @@ -77,10 +78,10 @@ export class PodLogList extends React.Component { */ @computed get logs() { - const showTimestamps = podLogsStore.getData(this.props.id).showTimestamps; + const showTimestamps = logTabStore.getData(this.props.id).showTimestamps; if (!showTimestamps) { - return podLogsStore.logsWithoutTimestamps; + return logStore.logsWithoutTimestamps; } return this.props.logs; @@ -206,19 +207,23 @@ export class PodLogList extends React.Component { const rowHeights = new Array(this.logs.length).fill(this.lineHeight); if (isInitLoading) { - return ; + return ( +
+ +
+ ); } if (!this.logs.length) { return ( -
+
There are no logs available for container
); } return ( -
+
) => void + reload: () => void +} + +export const LogResourceSelector = observer((props: Props) => { + const { tabData, save, reload, tabId } = props; + const { selectedPod, selectedContainer, pods } = tabData; + const pod = new Pod(selectedPod); + const containers = pod.getContainers(); + const initContainers = pod.getInitContainers(); + + const onContainerChange = (option: SelectOption) => { + save({ + selectedContainer: containers + .concat(initContainers) + .find(container => container.name === option.value) + }); + reload(); + }; + + const onPodChange = (option: SelectOption) => { + const selectedPod = podsStore.getByName(option.value, pod.getNs()); + + save({ selectedPod }); + logTabStore.renameTab(tabId); + }; + + const getSelectOptions = (items: string[]) => { + return items.map(item => { + return { + value: item, + label: item + }; + }); + }; + + const containerSelectOptions = [ + { + label: `Containers`, + options: getSelectOptions(containers.map(container => container.name)) + }, + { + label: `Init Containers`, + options: getSelectOptions(initContainers.map(container => container.name)), + } + ]; + + const podSelectOptions = [ + { + label: pod.getOwnerRefs()[0]?.name, + options: getSelectOptions(pods.map(pod => pod.metadata.name)) + } + ]; + + useEffect(() => { + reload(); + }, [selectedPod]); + + return ( +
+ Namespace + Pod + +
+ ); +}); diff --git a/src/renderer/components/dock/pod-log-search.scss b/src/renderer/components/dock/log-search.scss similarity index 61% rename from src/renderer/components/dock/pod-log-search.scss rename to src/renderer/components/dock/log-search.scss index 7d3ea9d92a..eec9a74749 100644 --- a/src/renderer/components/dock/pod-log-search.scss +++ b/src/renderer/components/dock/log-search.scss @@ -1,10 +1,13 @@ -.PodLogsSearch { +.LogSearch { .SearchInput { min-width: 150px; - width: 150px; .find-count { margin-left: 2px; } + + label { + padding-bottom: 7px; + } } } \ No newline at end of file diff --git a/src/renderer/components/dock/pod-log-search.tsx b/src/renderer/components/dock/log-search.tsx similarity index 85% rename from src/renderer/components/dock/pod-log-search.tsx rename to src/renderer/components/dock/log-search.tsx index 675c556c3e..a770b78592 100644 --- a/src/renderer/components/dock/pod-log-search.tsx +++ b/src/renderer/components/dock/log-search.tsx @@ -1,4 +1,4 @@ -import "./pod-log-search.scss"; +import "./log-search.scss"; import React, { useEffect } from "react"; import { observer } from "mobx-react"; @@ -16,7 +16,7 @@ interface Props extends PodLogSearchProps { logs: string[] } -export const PodLogSearch = observer((props: Props) => { +export const LogSearch = observer((props: Props) => { const { logs, onSearch, toPrevOverlay, toNextOverlay } = props; const { setNextOverlayActive, setPrevOverlayActive, searchQuery, occurrences, activeFind, totalFinds } = searchStore; const jumpDisabled = !searchQuery || !occurrences.length; @@ -57,11 +57,11 @@ export const PodLogSearch = observer((props: Props) => { }, [logs]); return ( -
+
0 && findCounts} onClear={onClear} onKeyDown={onKeyDown} @@ -78,11 +78,6 @@ export const PodLogSearch = observer((props: Props) => { onClick={onNextOverlay} disabled={jumpDisabled} /> -
); }); diff --git a/src/renderer/components/dock/log-tab.store.ts b/src/renderer/components/dock/log-tab.store.ts new file mode 100644 index 0000000000..3eec7812be --- /dev/null +++ b/src/renderer/components/dock/log-tab.store.ts @@ -0,0 +1,123 @@ +import uniqueId from "lodash/uniqueId"; +import { reaction } from "mobx"; +import { podsStore } from "../+workloads-pods/pods.store"; + +import { IPodContainer, Pod } from "../../api/endpoints"; +import { WorkloadKubeObject } from "../../api/workload-kube-object"; +import { DockTabStore } from "./dock-tab.store"; +import { dockStore, IDockTab, TabKind } from "./dock.store"; + +export interface LogTabData { + pods: Pod[]; + selectedPod: Pod; + selectedContainer: IPodContainer + showTimestamps?: boolean + previous?: boolean +} + +interface PodLogsTabData { + selectedPod: Pod + selectedContainer: IPodContainer +} + +interface WorkloadLogsTabData { + workload: WorkloadKubeObject +} + +export class LogTabStore extends DockTabStore { + constructor() { + super({ + storageName: "pod_logs" + }); + + reaction(() => podsStore.items.length, () => { + this.updateTabsData(); + }); + } + + createPodTab({ selectedPod, selectedContainer }: PodLogsTabData): void { + const podOwner = selectedPod.getOwnerRefs()[0]; + const pods = podsStore.getPodsByOwnerId(podOwner?.uid); + const title = `Pod ${selectedPod.getName()}`; + + this.createLogsTab(title, { + pods: pods.length ? pods : [selectedPod], + selectedPod, + selectedContainer + }); + } + + createWorkloadTab({ workload }: WorkloadLogsTabData): void { + const pods = podsStore.getPodsByOwnerId(workload.getId()); + + if (!pods.length) return; + + const selectedPod = pods[0]; + const selectedContainer = selectedPod.getAllContainers()[0]; + const title = `${workload.kind} ${selectedPod.getName()}`; + + this.createLogsTab(title, { + pods, + selectedPod, + selectedContainer + }); + } + + renameTab(tabId: string) { + const { selectedPod } = this.getData(tabId); + + dockStore.renameTab(tabId, `Pod ${selectedPod.metadata.name}`); + } + + private createDockTab(tabParams: Partial) { + dockStore.createTab({ + kind: TabKind.POD_LOGS, + ...tabParams + }, false); + } + + private createLogsTab(title: string, data: LogTabData) { + const id = uniqueId("log-tab-"); + + this.createDockTab({ id, title }); + this.setData(id, { + ...data, + showTimestamps: false, + previous: false + }); + } + + private updateTabsData() { + this.data.forEach((tabData, tabId) => { + const pod = new Pod(tabData.selectedPod); + const pods = podsStore.getPodsByOwnerId(pod.getOwnerRefs()[0]?.uid); + const isSelectedPodInList = pods.find(item => item.getId() == pod.getId()); + const selectedPod = isSelectedPodInList ? pod : pods[0]; + const selectedContainer = isSelectedPodInList ? tabData.selectedContainer : pod.getAllContainers()[0]; + + if (pods.length) { + this.setData(tabId, { + ...tabData, + selectedPod, + selectedContainer, + pods + }); + + this.renameTab(tabId); + } else { + this.closeTab(tabId); + } + }); + } + + private closeTab(tabId: string) { + this.clearData(tabId); + dockStore.closeTab(tabId); + } +} + +export const logTabStore = new LogTabStore(); + +export function isLogsTab(tab: IDockTab) { + return tab && tab.kind === TabKind.POD_LOGS; +} diff --git a/src/renderer/components/dock/pod-logs.store.ts b/src/renderer/components/dock/log.store.ts similarity index 62% rename from src/renderer/components/dock/pod-logs.store.ts rename to src/renderer/components/dock/log.store.ts index 9e69a15b7f..14dc9efdd0 100644 --- a/src/renderer/components/dock/pod-logs.store.ts +++ b/src/renderer/components/dock/log.store.ts @@ -1,27 +1,16 @@ -import { autorun, computed, observable, reaction } from "mobx"; -import { Pod, IPodContainer, podsApi, IPodLogsQuery } from "../../api/endpoints"; +import { autorun, computed, observable } from "mobx"; + +import { IPodLogsQuery, Pod, podsApi } from "../../api/endpoints"; import { autobind, interval } from "../../utils"; -import { DockTabStore } from "./dock-tab.store"; -import { dockStore, IDockTab, TabKind } from "./dock.store"; -import { searchStore } from "../../../common/search-store"; +import { dockStore, TabId } from "./dock.store"; +import { isLogsTab, logTabStore } from "./log-tab.store"; -export interface IPodLogsData { - pod: Pod; - selectedContainer: IPodContainer - containers: IPodContainer[] - initContainers: IPodContainer[] - showTimestamps: boolean - previous: boolean -} - -type TabId = string; type PodLogLine = string; -// Number for log lines to load -export const logRange = 500; +const logLinesToLoad = 500; @autobind() -export class PodLogsStore extends DockTabStore { +export class LogStore { private refresher = interval(10, () => { const id = dockStore.selectedTabId; @@ -30,30 +19,17 @@ export class PodLogsStore extends DockTabStore { }); @observable podLogs = observable.map(); - @observable newLogSince = observable.map(); // Timestamp after which all logs are considered to be new constructor() { - super({ - storageName: "pod_logs" - }); autorun(() => { const { selectedTab, isOpen } = dockStore; - if (isPodLogsTab(selectedTab) && isOpen) { + if (isLogsTab(selectedTab) && isOpen) { this.refresher.start(); } else { this.refresher.stop(); } }, { delay: 500 }); - - reaction(() => this.podLogs.get(dockStore.selectedTabId), () => { - this.setNewLogSince(dockStore.selectedTabId); - }); - - reaction(() => dockStore.selectedTabId, () => { - // Clear search query on tab change - searchStore.reset(); - }); } /** @@ -66,7 +42,7 @@ export class PodLogsStore extends DockTabStore { load = async (tabId: TabId) => { try { const logs = await this.loadLogs(tabId, { - tailLines: this.lines + logRange + tailLines: this.lines + logLinesToLoad }); this.refresher.start(); @@ -107,9 +83,9 @@ export class PodLogsStore extends DockTabStore { * @returns {Promise} A fetch request promise */ loadLogs = async (tabId: TabId, params: Partial) => { - const data = this.getData(tabId); + const data = logTabStore.getData(tabId); const { selectedContainer, previous } = data; - const pod = new Pod(data.pod); + const pod = new Pod(data.selectedPod); const namespace = pod.getNs(); const name = pod.getName(); @@ -127,17 +103,6 @@ export class PodLogsStore extends DockTabStore { }); }; - /** - * Sets newLogSince separator timestamp to split old logs from new ones - * @param tabId - */ - setNewLogSince(tabId: TabId) { - if (!this.podLogs.has(tabId) || !this.podLogs.get(tabId).length || this.newLogSince.has(tabId)) return; - const timestamp = this.getLastSinceTime(tabId); - - this.newLogSince.set(tabId, timestamp.split(".")[0]); // Removing milliseconds from string - } - /** * Converts logs into a string array * @returns {number} Length of log lines @@ -196,37 +161,6 @@ export class PodLogsStore extends DockTabStore { clearLogs(tabId: TabId) { this.podLogs.delete(tabId); } - - clearData(tabId: TabId) { - this.data.delete(tabId); - this.clearLogs(tabId); - } } -export const podLogsStore = new PodLogsStore(); - -export function createPodLogsTab(data: IPodLogsData, tabParams: Partial = {}) { - const podId = data.pod.getId(); - let tab = dockStore.getTabById(podId); - - if (tab) { - dockStore.open(); - dockStore.selectTab(tab.id); - - return; - } - // If no existent tab found - tab = dockStore.createTab({ - id: podId, - kind: TabKind.POD_LOGS, - title: data.pod.getName(), - ...tabParams - }, false); - podLogsStore.setData(tab.id, data); - - return tab; -} - -export function isPodLogsTab(tab: IDockTab) { - return tab && tab.kind === TabKind.POD_LOGS; -} +export const logStore = new LogStore(); diff --git a/src/renderer/components/dock/pod-logs.tsx b/src/renderer/components/dock/logs.tsx similarity index 53% rename from src/renderer/components/dock/pod-logs.tsx rename to src/renderer/components/dock/logs.tsx index 696c2bf0ab..0aa31f95fb 100644 --- a/src/renderer/components/dock/pod-logs.tsx +++ b/src/renderer/components/dock/logs.tsx @@ -6,9 +6,12 @@ import { searchStore } from "../../../common/search-store"; import { autobind } from "../../utils"; import { IDockTab } from "./dock.store"; import { InfoPanel } from "./info-panel"; -import { PodLogControls } from "./pod-log-controls"; -import { PodLogList } from "./pod-log-list"; -import { IPodLogsData, podLogsStore } from "./pod-logs.store"; +import { LogResourceSelector } from "./log-resource-selector"; +import { LogList } from "./log-list"; +import { logStore } from "./log.store"; +import { LogSearch } from "./log-search"; +import { LogControls } from "./log-controls"; +import { LogTabData, logTabStore } from "./log-tab.store"; interface Props { className?: string @@ -16,10 +19,10 @@ interface Props { } @observer -export class PodLogs extends React.Component { +export class Logs extends React.Component { @observable isLoading = true; - private logListElement = React.createRef(); // A reference for VirtualList component + private logListElement = React.createRef(); // A reference for VirtualList component componentDidMount() { disposeOnUnmount(this, @@ -28,7 +31,7 @@ export class PodLogs extends React.Component { } get tabData() { - return podLogsStore.getData(this.tabId); + return logTabStore.getData(this.tabId); } get tabId() { @@ -36,18 +39,18 @@ export class PodLogs extends React.Component { } @autobind() - save(data: Partial) { - podLogsStore.setData(this.tabId, { ...this.tabData, ...data }); + save(data: Partial) { + logTabStore.setData(this.tabId, { ...this.tabData, ...data }); } load = async () => { this.isLoading = true; - await podLogsStore.load(this.tabId); + await logStore.load(this.tabId); this.isLoading = false; }; reload = async () => { - podLogsStore.clearLogs(this.tabId); + logStore.clearLogs(this.tabId); await this.load(); }; @@ -79,39 +82,56 @@ export class PodLogs extends React.Component { }, 100); } - render() { - const logs = podLogsStore.logs; - + renderResourceSelector() { + const logs = logStore.logs; + const searchLogs = this.tabData.showTimestamps ? logs : logStore.logsWithoutTimestamps; const controls = ( - +
+ + +
); + return ( + + ); + } + + render() { + const logs = logStore.logs; + return (
- - +
); } diff --git a/src/renderer/components/dock/pod-log-controls.tsx b/src/renderer/components/dock/pod-log-controls.tsx deleted file mode 100644 index bd6cb9db15..0000000000 --- a/src/renderer/components/dock/pod-log-controls.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import "./pod-log-controls.scss"; -import React from "react"; -import { observer } from "mobx-react"; -import { IPodLogsData, podLogsStore } from "./pod-logs.store"; -import { Select, SelectOption } from "../select"; -import { Badge } from "../badge"; -import { Icon } from "../icon"; -import { cssNames, saveFileDialog } from "../../utils"; -import { Pod } from "../../api/endpoints"; -import { PodLogSearch, PodLogSearchProps } from "./pod-log-search"; - -interface Props extends PodLogSearchProps { - ready: boolean - tabId: string - tabData: IPodLogsData - logs: string[] - save: (data: Partial) => void - reload: () => void - onSearch: (query: string) => void -} - -export const PodLogControls = observer((props: Props) => { - const { tabData, save, reload, logs } = props; - const { selectedContainer, showTimestamps, previous } = tabData; - const since = logs.length ? podLogsStore.getTimestamps(logs[0]) : null; - const pod = new Pod(tabData.pod); - - const toggleTimestamps = () => { - save({ showTimestamps: !showTimestamps }); - }; - - const togglePrevious = () => { - save({ previous: !previous }); - reload(); - }; - - const downloadLogs = () => { - const fileName = selectedContainer ? selectedContainer.name : pod.getName(); - const logsToDownload = showTimestamps ? logs : podLogsStore.logsWithoutTimestamps; - - saveFileDialog(`${fileName}.log`, logsToDownload.join("\n"), "text/plain"); - }; - - const onContainerChange = (option: SelectOption) => { - const { containers, initContainers } = tabData; - - save({ - selectedContainer: containers - .concat(initContainers) - .find(container => container.name === option.value) - }); - reload(); - }; - - const containerSelectOptions = () => { - const { containers, initContainers } = tabData; - - return [ - { - label: `Containers`, - options: containers.map(container => { - return { value: container.name }; - }), - }, - { - label: `Init Containers`, - options: initContainers.map(container => { - return { value: container.name }; - }), - } - ]; - }; - - const formatOptionLabel = (option: SelectOption) => { - const { value, label } = option; - - return label || <> {value}; - }; - - return ( -
- Pod: - Namespace: - Container -