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

show lens-metrics on cluster settings

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen 2021-05-06 17:00:39 +03:00
parent 7b1b8e6321
commit 8b0b7fcbb2
18 changed files with 311 additions and 84 deletions

View File

@ -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<Catalog.KubernetesClusterCategory>("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 (
<section>
<section>
<MetricsSettings cluster={cluster} />
</section>
</section>
);
}
}
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 });
}
});
}
}
];
}

View File

@ -1,3 +1,4 @@
{{#if prometheus.enabled}}
apiVersion: v1
kind: Service
metadata:
@ -14,3 +15,4 @@ spec:
protocol: TCP
port: 80
targetPort: 9090
{{/if}}

View File

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

View File

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

View File

@ -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<Props> {
@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 && (
<section>
<p style={ {color: "var(--colorError)"} }>
Lens Metrics settings requires established connection to the cluster.
</p>
</section>
)}
{ this.canUpgrade && (
<section>
<Component.SubTitle title="Software Update" />
<Component.Button label={this.upgrading ? "Updating ..." : "Update Now"} primary onClick={() => this.updateStack() } waiting={this.upgrading} />
<small className="hint">
An update is available for installed metrics components.
</small>
</section>
)}
<section>
<Component.SubTitle title="Prometheus" />
<Component.FormSwitch
control={
<Component.Switcher
disabled={this.featureStates.kubeStateMetrics === undefined || !this.props.cluster.status.active}
checked={!!this.featureStates.prometheus && this.props.cluster.status.active}
onChange={v => this.togglePrometheus(v.target.checked)}
name="prometheus"
/>
}
label="Install built-in Prometheus metrics stack"
/>
<small className="hint">
Enable timeseries data visualization (Prometheus stack) for your cluster.
</small>
</section>
<section>
<Component.SubTitle title="Kube State Metrics" />
<Component.FormSwitch
control={
<Component.Switcher
disabled={this.featureStates.kubeStateMetrics === undefined || !this.props.cluster.status.active}
checked={!!this.featureStates.kubeStateMetrics && this.props.cluster.status.active}
onChange={v => this.toggleKubeStateMetrics(v.target.checked)}
name="node-exporter"
/>
}
label="Install built-in kube-state-metrics stack"
/>
<small className="hint">
Enable Kubernetes API object metrics for your cluster.
Install this only if you don&apos;t have existing kube-state-metrics stack installed.
</small>
</section>
<section>
<Component.SubTitle title="Node Exporter" />
<Component.FormSwitch
control={
<Component.Switcher
disabled={this.featureStates.nodeExporter === undefined || !this.props.cluster.status.active}
checked={!!this.featureStates.nodeExporter && this.props.cluster.status.active}
onChange={v => this.toggleNodeExporter(v.target.checked)}
name="node-exporter"
/>
}
label="Install built-in node-exporter stack"
/>
<small className="hint">
Enable node level metrics for your cluster.
Install this only if you don&apos;t have existing node-exporter stack installed.
</small>
</section>
</>
);
}
}

View File

@ -16,8 +16,8 @@
"jsx": "react"
},
"include": [
"./*.ts",
"./*.tsx"
"./**/*.ts",
"./**/*.tsx"
],
"exclude": [
"node_modules",

View File

@ -34,7 +34,6 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
async onContextMenuOpen(context: CatalogEntityContextMenuContext) {
context.menuItems = [
{
icon: "settings",
title: "Settings",
onlyVisibleForSource: "local",
onClick: async () => context.navigate(`/entity/${this.metadata.uid}/settings`)
@ -43,7 +42,6 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
if (this.metadata.labels["file"]?.startsWith(ClusterStore.storedKubeConfigFolder)) {
context.menuItems.push({
icon: "delete",
title: "Delete",
onlyVisibleForSource: "local",
onClick: async () => ClusterStore.getInstance().removeById(this.metadata.uid),
@ -54,14 +52,20 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
}
if (this.status.phase == "connected") {
context.menuItems.unshift({
icon: "link_off",
context.menuItems.push({
title: "Disconnect",
onClick: async () => {
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<KubernetesClusterCategory>(this);

View File

@ -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<void>;
@ -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<string, any>;

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ export {
CatalogEntityKindData,
CatalogEntityActionContext,
CatalogEntityAddMenuContext,
CatalogEntityAddMenu,
CatalogEntityContextMenu,
CatalogEntityContextMenuContext
} from "../../common/catalog";

View File

@ -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<CatalogAddButtonProps> {
@observable protected isOpen = false;
protected menuItems = observable.array<CatalogEntityContextMenu>([]);
protected menuItems = observable.array<CatalogEntityAddMenu>([]);
componentDidMount() {
disposeOnUnmount(this, [

View File

@ -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 (
<MenuActions onOpen={() => item.onContextMenuOpen(this.contextMenu)}>
<MenuItem key="add-to-hotbar" onClick={() => this.addToHotbar(item) }>
<Icon material="add" small interactive={true} title="Add to hotbar"/> Add to Hotbar
</MenuItem>
{
menuItems.map((menuItem, index) => (
<MenuItem key={index} onClick={() => this.onMenuItemClick(menuItem)}>
<Icon material={menuItem.icon} small interactive={true} title={menuItem.title} /> {menuItem.title}
{menuItem.title}
</MenuItem>
))
}
<MenuItem key="add-to-hotbar" onClick={() => this.addToHotbar(item) }>
Add to Hotbar
</MenuItem>
</MenuActions>
);
}

View File

@ -36,9 +36,15 @@ export class EntitySettings extends React.Component<Props> {
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) => {

View File

@ -159,16 +159,16 @@ export class HotbarIcon extends React.Component<Props> {
position={{right: true, bottom: true }} // FIXME: position does not work
open={() => onOpen()}
close={() => this.toggleMenu()}>
<MenuItem key="remove-from-hotbar" onClick={() => this.remove(entity) }>
<Icon material="clear" small interactive={true} title="Remove from hotbar"/> Remove from Hotbar
</MenuItem>
{ this.contextMenu && menuItems.map((menuItem) => {
return (
<MenuItem key={menuItem.title} onClick={() => this.onMenuItemClick(menuItem) }>
<Icon material={menuItem.icon} small interactive={true} title={menuItem.title}/> {menuItem.title}
{menuItem.title}
</MenuItem>
);
})}
<MenuItem key="remove-from-hotbar" onClick={() => this.remove(entity) }>
Remove from Hotbar
</MenuItem>
</Menu>
{children}
</div>

View File

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