diff --git a/extensions/metrics-cluster-feature/renderer.tsx b/extensions/metrics-cluster-feature/renderer.tsx index 030228d50c..5909f0673f 100644 --- a/extensions/metrics-cluster-feature/renderer.tsx +++ b/extensions/metrics-cluster-feature/renderer.tsx @@ -1,55 +1,27 @@ -import { LensRendererExtension, Interface, Component, Catalog} from "@k8slens/extensions"; -import { MetricsFeature } from "./src/metrics-feature"; +import React from "react"; +import { LensRendererExtension, Catalog } from "@k8slens/extensions"; +import { MetricsSettings } from "./src/metrics-settings"; export default class ClusterMetricsFeatureExtension extends LensRendererExtension { - onActivate() { - const category = Catalog.catalogCategories.getForGroupKind("entity.k8slens.dev", "KubernetesCluster"); + entitySettings = [ + { + apiVersions: ["entity.k8slens.dev/v1alpha1"], + kind: "KubernetesCluster", + title: "Lens Metrics", + priority: 5, + components: { + View: (props: {entity: Catalog.KubernetesCluster}) => { // eslint-disable-line + const cluster = props.entity; // eslint-disable-line - if (!category) { - return; - } - - category.on("contextMenuOpen", this.clusterContextMenuOpen.bind(this)); - } - - async clusterContextMenuOpen(cluster: Catalog.KubernetesCluster, ctx: Interface.CatalogEntityContextMenuContext) { - if (!cluster.status.active) { - return; - } - - const metricsFeature = new MetricsFeature(); - - await metricsFeature.updateStatus(cluster); - - if (metricsFeature.status.installed) { - if (metricsFeature.status.canUpgrade) { - ctx.menuItems.unshift({ - icon: "refresh", - title: "Upgrade Lens Metrics stack", - onClick: async () => { - metricsFeature.upgrade(cluster); - } - }); + return ( +
+
+ +
+
+ ); + } } - ctx.menuItems.unshift({ - icon: "toggle_off", - title: "Uninstall Lens Metrics stack", - onClick: async () => { - await metricsFeature.uninstall(cluster); - - Component.Notifications.info(`Lens Metrics has been removed from ${cluster.metadata.name}`, { timeout: 10_000 }); - } - }); - } else { - ctx.menuItems.unshift({ - icon: "toggle_on", - title: "Install Lens Metrics stack", - onClick: async () => { - metricsFeature.install(cluster); - - Component.Notifications.info(`Lens Metrics is now installed to ${cluster.metadata.name}`, { timeout: 10_000 }); - } - }); } - } + ]; } diff --git a/extensions/metrics-cluster-feature/resources/03-service.yml b/extensions/metrics-cluster-feature/resources/03-service.yml.hb similarity index 88% rename from extensions/metrics-cluster-feature/resources/03-service.yml rename to extensions/metrics-cluster-feature/resources/03-service.yml.hb index 1eb0cb5117..3cdcdbc260 100644 --- a/extensions/metrics-cluster-feature/resources/03-service.yml +++ b/extensions/metrics-cluster-feature/resources/03-service.yml.hb @@ -1,3 +1,4 @@ +{{#if prometheus.enabled}} apiVersion: v1 kind: Service metadata: @@ -14,3 +15,4 @@ spec: protocol: TCP port: 80 targetPort: 9090 +{{/if}} diff --git a/extensions/metrics-cluster-feature/resources/03-statefulset.yml.hb b/extensions/metrics-cluster-feature/resources/03-statefulset.yml.hb index dba437ee7d..627ab925eb 100644 --- a/extensions/metrics-cluster-feature/resources/03-statefulset.yml.hb +++ b/extensions/metrics-cluster-feature/resources/03-statefulset.yml.hb @@ -1,3 +1,4 @@ +{{#if prometheus.enabled}} apiVersion: apps/v1 kind: StatefulSet metadata: @@ -53,7 +54,7 @@ spec: mountPath: /var/lib/prometheus containers: - name: prometheus - image: quay.io/prometheus/prometheus:v2.19.3 + image: quay.io/prometheus/prometheus:v2.20.1 args: - --web.listen-address=0.0.0.0:9090 - --config.file=/etc/prometheus/prometheus.yaml @@ -114,3 +115,4 @@ spec: requests: storage: {{persistence.size}} {{/if}} +{{/if}} diff --git a/extensions/metrics-cluster-feature/src/metrics-feature.ts b/extensions/metrics-cluster-feature/src/metrics-feature.ts index 747d6030dc..11181f9199 100644 --- a/extensions/metrics-cluster-feature/src/metrics-feature.ts +++ b/extensions/metrics-cluster-feature/src/metrics-feature.ts @@ -4,6 +4,9 @@ import * as path from "path"; export interface MetricsConfiguration { // Placeholder for Metrics config structure + prometheus: { + enabled: boolean; + }; persistence: { enabled: boolean; storageClass: string; @@ -29,20 +32,23 @@ export class MetricsFeature extends ClusterFeature.Feature { latestVersion = "v2.19.3-lens1"; templateContext: MetricsConfiguration = { + prometheus: { + enabled: false + }, persistence: { enabled: false, storageClass: null, size: "20G", }, nodeExporter: { - enabled: true, + enabled: false, }, retention: { time: "2d", size: "5GB", }, kubeStateMetrics: { - enabled: true, + enabled: false, }, alertManagers: null, replicas: 1, @@ -59,7 +65,7 @@ export class MetricsFeature extends ClusterFeature.Feature { sc.metadata?.annotations?.["storageclass.beta.kubernetes.io/is-default-class"] === "true" )); - super.applyResources(cluster, path.join(__dirname, "../resources/")); + await super.applyResources(cluster, path.join(__dirname, "../resources/")); } async upgrade(cluster: Catalog.KubernetesCluster): Promise { @@ -75,6 +81,13 @@ export class MetricsFeature extends ClusterFeature.Feature { this.status.installed = true; this.status.currentVersion = prometheus.spec.template.spec.containers[0].image.split(":")[1]; this.status.canUpgrade = semver.lt(this.status.currentVersion, this.latestVersion, true); + + const pvcTemplate = prometheus.spec.volumeClaimTemplates[0]; + + if (pvcTemplate) { + this.templateContext.persistence.enabled = true; + this.templateContext.persistence.size = pvcTemplate.spec.resources.requests.storage; + } } else { this.status.installed = false; } diff --git a/extensions/metrics-cluster-feature/src/metrics-settings.tsx b/extensions/metrics-cluster-feature/src/metrics-settings.tsx new file mode 100644 index 0000000000..1473c27c37 --- /dev/null +++ b/extensions/metrics-cluster-feature/src/metrics-settings.tsx @@ -0,0 +1,213 @@ +import React from "react"; +import { Component, Catalog, K8sApi } from "@k8slens/extensions"; +import { observer } from "mobx-react"; +import { observable } from "mobx"; +import { MetricsFeature } from "./metrics-feature"; +interface Props { + cluster: Catalog.KubernetesCluster; +} + +@observer +export class MetricsSettings extends React.Component { + @observable featureStates = { + prometheus: false, + kubeStateMetrics: false, + nodeExporter: false + }; + @observable canUpgrade = false; + @observable upgrading = false; + + pvcOptions = [ + { value: "", label: "Use EmptyDir (no PVC)" }, + { value: "5GB", label: "5GB" }, + { value: "10GB", label: "10GB" }, + { value: "20GB", label: "20GB" }, + { value: "40GB", label: "40GB" }, + { value: "80GB", label: "80GB" }, + ]; + + featureSet: MetricsFeature; + + async componentDidMount() { + this.featureSet = new MetricsFeature(); + + await this.updateFeatureStates(); + } + + async updateFeatureStates() { + await this.featureSet.updateStatus(this.props.cluster); + this.canUpgrade = this.featureSet.status.canUpgrade; + + this.featureStates.prometheus = this.featureSet.status.installed; + + const deployment = K8sApi.forCluster(this.props.cluster, K8sApi.Deployment); + + try { + await deployment.get({name: "kube-state-metrics", namespace: "lens-metrics"}); + this.featureStates.kubeStateMetrics = true; + } catch(e) { + if (e?.error?.code === 404) { + this.featureStates.kubeStateMetrics = false; + } else { + this.featureStates.kubeStateMetrics = undefined; + } + } + + const daemonSet = K8sApi.forCluster(this.props.cluster, K8sApi.DaemonSet); + + try { + await daemonSet.get({name: "node-exporter", namespace: "lens-metrics"}); + this.featureStates.nodeExporter = true; + } catch(e) { + if (e?.error?.code === 404) { + this.featureStates.nodeExporter = false; + } else { + this.featureStates.nodeExporter = undefined; + } + } + } + + async save() { + const ctx = this.featureSet.templateContext; + + ctx.prometheus.enabled = !!this.featureStates.prometheus; + ctx.kubeStateMetrics.enabled = !!this.featureStates.kubeStateMetrics; + ctx.nodeExporter.enabled = !!this.featureStates.nodeExporter; + + if (!ctx.prometheus.enabled && !ctx.kubeStateMetrics.enabled && !ctx.nodeExporter.enabled) { + await this.featureSet.uninstall(this.props.cluster); + } else { + await this.featureSet.install(this.props.cluster); + } + } + + async togglePrometheus(enabled: boolean) { + this.featureStates.prometheus = enabled; + + this.featureSet.templateContext.prometheus.enabled = this.featureStates.prometheus; + + try { + await this.save(); + } catch(error) { + this.featureStates.prometheus = !enabled; + Component.Notifications.error(`Failed to ${enabled ? "enable" : "disable"} Prometheus: ${error}`); + } + } + + async toggleKubeStateMetrics(enabled: boolean) { + this.featureStates.kubeStateMetrics = enabled; + + this.featureSet.templateContext.kubeStateMetrics.enabled = this.featureStates.kubeStateMetrics; + + try { + await this.save(); + } catch(error) { + this.featureStates.kubeStateMetrics = !enabled; + Component.Notifications.error(`Failed to ${enabled ? "enable" : "disable"} kube-state-metrics: ${error}`); + } + } + + async toggleNodeExporter(enabled: boolean) { + this.featureStates.nodeExporter = enabled; + + this.featureSet.templateContext.nodeExporter.enabled = this.featureStates.nodeExporter; + + try { + await this.save(); + } catch(error) { + this.featureStates.nodeExporter = !enabled; + Component.Notifications.error(`Failed to ${enabled ? "enable" : "disable"} node-exporter: ${error}`); + } + } + + async updateStack() { + this.upgrading = true; + + await this.save(); + setTimeout(() => { + Component.Notifications.info("Lens Metrics stack updated!", {timeout: 5_000}); + this.upgrading = false; + this.canUpgrade = false; + }, 1000); + } + + render() { + return ( + <> + { !this.props.cluster.status.active && ( +
+

+ Lens Metrics settings requires established connection to the cluster. +

+
+ )} + { this.canUpgrade && ( +
+ + + this.updateStack() } waiting={this.upgrading} /> + + + An update is available for installed metrics components. + +
+ )} +
+ + this.togglePrometheus(v.target.checked)} + name="prometheus" + /> + } + label="Install built-in Prometheus metrics stack" + /> + + Enable timeseries data visualization (Prometheus stack) for your cluster. + +
+ +
+ + this.toggleKubeStateMetrics(v.target.checked)} + name="node-exporter" + /> + } + label="Install built-in kube-state-metrics stack" + /> + + Enable Kubernetes API object metrics for your cluster. + Install this only if you don't have existing kube-state-metrics stack installed. + +
+ +
+ + this.toggleNodeExporter(v.target.checked)} + name="node-exporter" + /> + } + label="Install built-in node-exporter stack" + /> + + Enable node level metrics for your cluster. + Install this only if you don't have existing node-exporter stack installed. + +
+ + ); + } +} diff --git a/extensions/metrics-cluster-feature/tsconfig.json b/extensions/metrics-cluster-feature/tsconfig.json index a93ad6fe9f..016d32b0ba 100644 --- a/extensions/metrics-cluster-feature/tsconfig.json +++ b/extensions/metrics-cluster-feature/tsconfig.json @@ -16,8 +16,8 @@ "jsx": "react" }, "include": [ - "./*.ts", - "./*.tsx" + "./**/*.ts", + "./**/*.tsx" ], "exclude": [ "node_modules", diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index 5f2737eba2..21400aa9bd 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -34,7 +34,6 @@ export class KubernetesCluster extends CatalogEntity context.navigate(`/entity/${this.metadata.uid}/settings`) @@ -43,7 +42,6 @@ export class KubernetesCluster extends CatalogEntity ClusterStore.getInstance().removeById(this.metadata.uid), @@ -54,14 +52,20 @@ export class KubernetesCluster extends CatalogEntity { ClusterStore.getInstance().deactivate(this.metadata.uid); requestMain(clusterDisconnectHandler, this.metadata.uid); } }); + } else { + context.menuItems.push({ + title: "Connect", + onClick: async () => { + context.navigate(`/cluster/${this.metadata.uid}`); + } + }); } const category = catalogCategoryRegistry.getCategoryForEntity(this); diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index 2062347f4a..c352d59638 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -62,7 +62,6 @@ export interface CatalogEntityActionContext { } export interface CatalogEntityContextMenu { - icon: string; title: string; onlyVisibleForSource?: string; // show only if empty or if matches with entity source onClick: () => void | Promise; @@ -71,6 +70,10 @@ export interface CatalogEntityContextMenu { } } +export interface CatalogEntityAddMenu extends CatalogEntityContextMenu { + icon: string; +} + export interface CatalogEntitySettingsMenu { group?: string; title: string; @@ -90,7 +93,7 @@ export interface CatalogEntitySettingsContext { export interface CatalogEntityAddMenuContext { navigate: (url: string) => void; - menuItems: CatalogEntityContextMenu[]; + menuItems: CatalogEntityAddMenu[]; } export type CatalogEntitySpec = Record; diff --git a/src/common/cluster-ipc.ts b/src/common/cluster-ipc.ts index 949fee1117..b0165dc74f 100644 --- a/src/common/cluster-ipc.ts +++ b/src/common/cluster-ipc.ts @@ -46,14 +46,20 @@ if (ipcMain) { } }); - handleRequest(clusterKubectlApplyAllHandler, (event, clusterId: ClusterId, resources: string[]) => { + handleRequest(clusterKubectlApplyAllHandler, async (event, clusterId: ClusterId, resources: string[]) => { appEventBus.emit({name: "cluster", action: "kubectl-apply-all"}); const cluster = ClusterStore.getInstance().getById(clusterId); if (cluster) { const applier = new ResourceApplier(cluster); - applier.kubectlApplyAll(resources); + try { + const stdout = await applier.kubectlApplyAll(resources); + + return { stdout }; + } catch (error: any) { + return { stderr: error }; + } } else { throw `${clusterId} is not a valid cluster id`; } diff --git a/src/extensions/cluster-feature.ts b/src/extensions/cluster-feature.ts index fa4fe0e48f..e762ae5222 100644 --- a/src/extensions/cluster-feature.ts +++ b/src/extensions/cluster-feature.ts @@ -99,9 +99,9 @@ export abstract class ClusterFeature { } if (app) { - await new ResourceApplier(clusterModel).kubectlApplyAll(resources); + return await new ResourceApplier(clusterModel).kubectlApplyAll(resources); } else { - await requestMain(clusterKubectlApplyAllHandler, cluster.metadata.uid, resources); + return await requestMain(clusterKubectlApplyAllHandler, cluster.metadata.uid, resources); } } diff --git a/src/extensions/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index 1a48fc54e3..0784163891 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -13,6 +13,7 @@ export * from "../../renderer/components/checkbox"; export * from "../../renderer/components/radio"; export * from "../../renderer/components/select"; export * from "../../renderer/components/slider"; +export * from "../../renderer/components/switch"; export * from "../../renderer/components/input/input"; // command-overlay diff --git a/src/main/resource-applier.ts b/src/main/resource-applier.ts index 6f1b0a8e0f..cb245c4788 100644 --- a/src/main/resource-applier.ts +++ b/src/main/resource-applier.ts @@ -67,16 +67,19 @@ export class ResourceApplier { const cmd = `"${kubectlPath}" apply --kubeconfig "${proxyKubeconfigPath}" -o json -f "${tmpDir}"`; console.log("shooting manifests with:", cmd); - exec(cmd, (error, stdout, stderr) => { + exec(cmd, (error, stdout) => { if (error) { - reject(`Error applying manifests:${error}`); - } + const splittedError = error.toString().split(`.yaml": `); - if (stderr != "") { - reject(stderr); + if (splittedError[1]) { + reject(`Error applying manifests: ${splittedError[1]}`); + } else { + reject(`Error applying manifests:${error}`); + } return; } + resolve(stdout); }); }); diff --git a/src/renderer/api/catalog-entity.ts b/src/renderer/api/catalog-entity.ts index 705baebf36..c6e794ad17 100644 --- a/src/renderer/api/catalog-entity.ts +++ b/src/renderer/api/catalog-entity.ts @@ -9,6 +9,7 @@ export { CatalogEntityKindData, CatalogEntityActionContext, CatalogEntityAddMenuContext, + CatalogEntityAddMenu, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../common/catalog"; diff --git a/src/renderer/components/+catalog/catalog-add-button.tsx b/src/renderer/components/+catalog/catalog-add-button.tsx index 22ef59df15..b6fe602676 100644 --- a/src/renderer/components/+catalog/catalog-add-button.tsx +++ b/src/renderer/components/+catalog/catalog-add-button.tsx @@ -5,7 +5,7 @@ import { Icon } from "../icon"; import { disposeOnUnmount, observer } from "mobx-react"; import { observable, reaction } from "mobx"; import { autobind } from "../../../common/utils"; -import { CatalogCategory, CatalogEntityAddMenuContext, CatalogEntityContextMenu } from "../../api/catalog-entity"; +import { CatalogCategory, CatalogEntityAddMenuContext, CatalogEntityAddMenu } from "../../api/catalog-entity"; import { EventEmitter } from "events"; import { navigate } from "../../navigation"; @@ -16,7 +16,7 @@ export type CatalogAddButtonProps = { @observer export class CatalogAddButton extends React.Component { @observable protected isOpen = false; - protected menuItems = observable.array([]); + protected menuItems = observable.array([]); componentDidMount() { disposeOnUnmount(this, [ diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 1db5d2d2c6..f63c762780 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -8,7 +8,6 @@ import { navigate } from "../../navigation"; import { kebabCase } from "lodash"; import { PageLayout } from "../layout/page-layout"; import { MenuItem, MenuActions } from "../menu"; -import { Icon } from "../icon"; import { CatalogEntityContextMenu, CatalogEntityContextMenuContext, catalogEntityRunContext } from "../../api/catalog-entity"; import { Badge } from "../badge"; import { HotbarStore } from "../../../common/hotbar-store"; @@ -115,16 +114,16 @@ export class Catalog extends React.Component { return ( item.onContextMenuOpen(this.contextMenu)}> - this.addToHotbar(item) }> - Add to Hotbar - { menuItems.map((menuItem, index) => ( this.onMenuItemClick(menuItem)}> - {menuItem.title} + {menuItem.title} )) } + this.addToHotbar(item) }> + Add to Hotbar + ); } diff --git a/src/renderer/components/+entity-settings/entity-settings.tsx b/src/renderer/components/+entity-settings/entity-settings.tsx index ef21e2b3ad..3d0a8f26a3 100644 --- a/src/renderer/components/+entity-settings/entity-settings.tsx +++ b/src/renderer/components/+entity-settings/entity-settings.tsx @@ -36,9 +36,15 @@ export class EntitySettings extends React.Component { async componentDidMount() { const { hash } = navigation.location; - this.ensureActiveTab(); + if (hash) { + const item = this.menuItems.find((item) => item.title === hash.slice(1)); - document.getElementById(hash.slice(1))?.scrollIntoView(); + if (item) { + this.activeTab = item.id; + } + } + + this.ensureActiveTab(); } onTabChange = (tabId: string) => { diff --git a/src/renderer/components/hotbar/hotbar-icon.tsx b/src/renderer/components/hotbar/hotbar-icon.tsx index 82be0ee88c..c9e1c3b595 100644 --- a/src/renderer/components/hotbar/hotbar-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-icon.tsx @@ -159,16 +159,16 @@ export class HotbarIcon extends React.Component { position={{right: true, bottom: true }} // FIXME: position does not work open={() => onOpen()} close={() => this.toggleMenu()}> - this.remove(entity) }> - Remove from Hotbar - { this.contextMenu && menuItems.map((menuItem) => { return ( this.onMenuItemClick(menuItem) }> - {menuItem.title} + {menuItem.title} ); })} + this.remove(entity) }> + Remove from Hotbar + {children} diff --git a/src/renderer/components/menu/menu.scss b/src/renderer/components/menu/menu.scss index e7e4cee7e6..c42d359d00 100644 --- a/src/renderer/components/menu/menu.scss +++ b/src/renderer/components/menu/menu.scss @@ -1,5 +1,5 @@ .Menu { - --bgc: #{$contentColor}; + --bgc: #{$layoutBackground}; position: absolute; display: flex; @@ -8,6 +8,8 @@ list-style: none; border: 1px solid $borderColor; z-index: 101; + box-shadow: rgba(0,0,0,0.24) 0px 8px 16px 0px; + border-radius: 4px; &.portal { left: -1000px;