mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Allow extensions to define cluster features (#1125)
Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
1 parent
f3a0059355
commit
62ae7771df
81 files changed
+4065
-506
No files matched your search
@@ -0,0 +1,5 @@
|
||||
install-deps:
|
||||
npm install
|
||||
|
||||
build: install-deps
|
||||
npm run build
|
||||
+3570
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "lens-metrics-cluster-feature",
|
||||
"version": "0.1.0",
|
||||
"description": "Lens metrics cluster feature",
|
||||
"renderer": "dist/renderer.js",
|
||||
"lens": {
|
||||
"metadata": {},
|
||||
"styles": []
|
||||
},
|
||||
"scripts": {
|
||||
"build": "webpack --config webpack.config.js",
|
||||
"dev": "npm run build --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"semver": "^7.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ts-loader": "^8.0.4",
|
||||
"typescript": "^4.0.3",
|
||||
"webpack": "^4.44.2",
|
||||
"mobx": "^5.15.5",
|
||||
"react": "^16.13.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Registry, LensRendererExtension } from "@k8slens/extensions"
|
||||
import { MetricsFeature } from "./src/metrics-feature"
|
||||
import React from "react"
|
||||
|
||||
export default class ClusterMetricsFeatureExtension extends LensRendererExtension {
|
||||
registerClusterFeatures(registry: Registry.ClusterFeatureRegistry) {
|
||||
this.disposers.push(
|
||||
registry.add({
|
||||
title: "Metrics Stack",
|
||||
components: {
|
||||
Description: () => {
|
||||
return (
|
||||
<span>
|
||||
Enable timeseries data visualization (Prometheus stack) for your cluster.
|
||||
Install this only if you don't have existing Prometheus stack installed.
|
||||
You can see preview of manifests <a href="https://github.com/lensapp/lens/tree/master/extensions/lens-metrics/resources" target="_blank">here</a>.
|
||||
</span>
|
||||
)
|
||||
}
|
||||
},
|
||||
feature: new MetricsFeature()
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,96 @@
|
||||
import { ClusterFeature, Store, K8sApi } from "@k8slens/extensions"
|
||||
import semver from "semver"
|
||||
import * as path from "path"
|
||||
|
||||
export interface MetricsConfiguration {
|
||||
// Placeholder for Metrics config structure
|
||||
persistence: {
|
||||
enabled: boolean;
|
||||
storageClass: string;
|
||||
size: string;
|
||||
};
|
||||
nodeExporter: {
|
||||
enabled: boolean;
|
||||
};
|
||||
kubeStateMetrics: {
|
||||
enabled: boolean;
|
||||
};
|
||||
retention: {
|
||||
time: string;
|
||||
size: string;
|
||||
};
|
||||
alertManagers: string[];
|
||||
replicas: number;
|
||||
storageClass: string;
|
||||
}
|
||||
|
||||
export class MetricsFeature extends ClusterFeature.Feature {
|
||||
name = "metrics"
|
||||
latestVersion = "v2.17.2-lens1"
|
||||
|
||||
config: MetricsConfiguration = {
|
||||
persistence: {
|
||||
enabled: false,
|
||||
storageClass: null,
|
||||
size: "20G",
|
||||
},
|
||||
nodeExporter: {
|
||||
enabled: true,
|
||||
},
|
||||
retention: {
|
||||
time: "2d",
|
||||
size: "5GB",
|
||||
},
|
||||
kubeStateMetrics: {
|
||||
enabled: true,
|
||||
},
|
||||
alertManagers: null,
|
||||
replicas: 1,
|
||||
storageClass: null,
|
||||
};
|
||||
|
||||
async install(cluster: Store.Cluster): Promise<void> {
|
||||
// Check if there are storageclasses
|
||||
const storageClassApi = K8sApi.forCluster(cluster, K8sApi.StorageClass)
|
||||
const scs = await storageClassApi.list()
|
||||
this.config.persistence.enabled = scs.some(sc => (
|
||||
sc.metadata?.annotations?.['storageclass.kubernetes.io/is-default-class'] === 'true' ||
|
||||
sc.metadata?.annotations?.['storageclass.beta.kubernetes.io/is-default-class'] === 'true'
|
||||
));
|
||||
|
||||
super.applyResources(cluster, super.renderTemplates(path.join(__dirname, "../resources/")))
|
||||
}
|
||||
|
||||
async upgrade(cluster: Store.Cluster): Promise<void> {
|
||||
return this.install(cluster)
|
||||
}
|
||||
|
||||
async updateStatus(cluster: Store.Cluster): Promise<ClusterFeature.FeatureStatus> {
|
||||
try {
|
||||
const statefulSet = K8sApi.forCluster(cluster, K8sApi.StatefulSet)
|
||||
const prometheus = await statefulSet.get({name: "prometheus", namespace: "lens-metrics"})
|
||||
if (prometheus?.kind) {
|
||||
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);
|
||||
} else {
|
||||
this.status.installed = false
|
||||
}
|
||||
} catch(e) {
|
||||
if (e?.error?.code === 404) {
|
||||
this.status.installed = false
|
||||
}
|
||||
}
|
||||
|
||||
return this.status
|
||||
}
|
||||
|
||||
async uninstall(cluster: Store.Cluster): Promise<void> {
|
||||
const namespaceApi = K8sApi.forCluster(cluster, K8sApi.Namespace)
|
||||
const clusterRoleBindingApi = K8sApi.forCluster(cluster, K8sApi.ClusterRoleBinding)
|
||||
const clusterRoleApi = K8sApi.forCluster(cluster, K8sApi.ClusterRole)
|
||||
|
||||
await namespaceApi.delete({name: "lens-metrics"})
|
||||
await clusterRoleBindingApi.delete({name: "lens-prometheus"})
|
||||
await clusterRoleApi.delete({name: "lens-prometheus"}) }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"module": "CommonJS",
|
||||
"target": "ES2017",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"moduleResolution": "Node",
|
||||
"sourceMap": false,
|
||||
"declaration": false,
|
||||
"strict": false,
|
||||
"noImplicitAny": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"experimentalDecorators": true,
|
||||
"jsx": "react"
|
||||
},
|
||||
"include": [
|
||||
"../../src/extensions/npm/**/*.d.ts",
|
||||
"./*.ts",
|
||||
"./*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"*.js"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
entry: './renderer.tsx',
|
||||
context: __dirname,
|
||||
target: "electron-renderer",
|
||||
mode: "production",
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: 'ts-loader',
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
],
|
||||
},
|
||||
externals: [
|
||||
{
|
||||
"@k8slens/extensions": "var global.LensExtensions",
|
||||
"react": "var global.React",
|
||||
"mobx": "var global.Mobx"
|
||||
}
|
||||
],
|
||||
resolve: {
|
||||
extensions: [ '.tsx', '.ts', '.js' ],
|
||||
},
|
||||
output: {
|
||||
libraryTarget: "commonjs2",
|
||||
globalObject: "this",
|
||||
filename: 'renderer.js',
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
},
|
||||
node: {
|
||||
__dirname: false
|
||||
}
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
install-deps:
|
||||
npm install
|
||||
|
||||
build: install-deps
|
||||
npm run build
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"renderer.ts",
|
||||
"renderer.tsx",
|
||||
"../../src/extensions/npm/**/*.d.ts",
|
||||
"src/**/*"
|
||||
]
|
||||
|
||||
@@ -177,6 +177,7 @@
|
||||
"telemetry",
|
||||
"pod-menu",
|
||||
"node-menu",
|
||||
"metrics-cluster-feature",
|
||||
"support-page"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createIpcChannel } from "./ipc";
|
||||
import { ClusterId, clusterStore } from "./cluster-store";
|
||||
import { extensionLoader } from "../extensions/extension-loader"
|
||||
import { appEventBus } from "./event-bus"
|
||||
import { ResourceApplier } from "../main/resource-applier";
|
||||
|
||||
export const clusterIpc = {
|
||||
activate: createIpcChannel({
|
||||
@@ -42,32 +43,17 @@ export const clusterIpc = {
|
||||
},
|
||||
}),
|
||||
|
||||
installFeature: createIpcChannel({
|
||||
channel: "cluster:install-feature",
|
||||
handle: async (clusterId: ClusterId, feature: string, config?: any) => {
|
||||
appEventBus.emit({name: "cluster", action: "install", params: { feature: feature}})
|
||||
kubectlApplyAll: createIpcChannel({
|
||||
channel: "cluster:kubectl-apply-all",
|
||||
handle: (clusterId: ClusterId, resources: string[]) => {
|
||||
appEventBus.emit({name: "cluster", action: "kubectl-apply-all"})
|
||||
const cluster = clusterStore.getById(clusterId);
|
||||
if (cluster) {
|
||||
await cluster.installFeature(feature, config)
|
||||
const applier = new ResourceApplier(cluster)
|
||||
applier.kubectlApplyAll(resources)
|
||||
} else {
|
||||
throw `${clusterId} is not a valid cluster id`;
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
uninstallFeature: createIpcChannel({
|
||||
channel: "cluster:uninstall-feature",
|
||||
handle: (clusterId: ClusterId, feature: string) => {
|
||||
appEventBus.emit({name: "cluster", action: "uninstall", params: { feature: feature}})
|
||||
return clusterStore.getById(clusterId)?.uninstallFeature(feature)
|
||||
}
|
||||
}),
|
||||
|
||||
upgradeFeature: createIpcChannel({
|
||||
channel: "cluster:upgrade-feature",
|
||||
handle: (clusterId: ClusterId, feature: string, config?: any) => {
|
||||
appEventBus.emit({name: "cluster", action: "upgrade", params: { feature: feature}})
|
||||
return clusterStore.getById(clusterId)?.upgradeFeature(feature, config)
|
||||
}
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import fs from "fs";
|
||||
import path from "path"
|
||||
import hb from "handlebars"
|
||||
import { observable } from "mobx"
|
||||
import { ResourceApplier } from "../main/resource-applier"
|
||||
import { Cluster } from "../main/cluster";
|
||||
import logger from "../main/logger";
|
||||
import { app } from "electron"
|
||||
import { clusterIpc } from "../common/cluster-ipc"
|
||||
|
||||
export interface ClusterFeatureStatus {
|
||||
currentVersion: string;
|
||||
installed: boolean;
|
||||
latestVersion: string;
|
||||
canUpgrade: boolean;
|
||||
}
|
||||
|
||||
export abstract class ClusterFeature {
|
||||
name: string;
|
||||
latestVersion: string;
|
||||
config: any;
|
||||
|
||||
@observable status: ClusterFeatureStatus = {
|
||||
currentVersion: null,
|
||||
installed: false,
|
||||
latestVersion: null,
|
||||
canUpgrade: false
|
||||
}
|
||||
|
||||
abstract async install(cluster: Cluster): Promise<void>;
|
||||
|
||||
abstract async upgrade(cluster: Cluster): Promise<void>;
|
||||
|
||||
abstract async uninstall(cluster: Cluster): Promise<void>;
|
||||
|
||||
abstract async updateStatus(cluster: Cluster): Promise<ClusterFeatureStatus>;
|
||||
|
||||
protected async applyResources(cluster: Cluster, resources: string[]) {
|
||||
if (app) {
|
||||
await new ResourceApplier(cluster).kubectlApplyAll(resources)
|
||||
} else {
|
||||
await clusterIpc.kubectlApplyAll.invokeFromRenderer(cluster.id, resources)
|
||||
}
|
||||
}
|
||||
|
||||
protected renderTemplates(folderPath: string): string[] {
|
||||
const resources: string[] = [];
|
||||
logger.info(`[FEATURE]: render templates from ${folderPath}`);
|
||||
fs.readdirSync(folderPath).forEach(filename => {
|
||||
const file = path.join(folderPath, filename);
|
||||
const raw = fs.readFileSync(file);
|
||||
if (filename.endsWith('.hb')) {
|
||||
const template = hb.compile(raw.toString());
|
||||
resources.push(template(this.config));
|
||||
} else {
|
||||
resources.push(raw.toString());
|
||||
}
|
||||
});
|
||||
|
||||
return resources;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ClusterFeature as Feature, ClusterFeatureStatus as FeatureStatus } from "../cluster-feature"
|
||||
@@ -10,11 +10,13 @@ import * as Store from "./stores"
|
||||
import * as Util from "./utils"
|
||||
import * as Registry from "../registries"
|
||||
import * as CommonVars from "../../common/vars";
|
||||
import * as ClusterFeature from "./cluster-feature"
|
||||
|
||||
export let windowManager: WindowManager;
|
||||
|
||||
export {
|
||||
EventBus,
|
||||
ClusterFeature,
|
||||
Store,
|
||||
Util,
|
||||
Registry,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { ExtensionStore } from "../extension-store"
|
||||
export type { Cluster } from "../../main/cluster"
|
||||
@@ -0,0 +1,19 @@
|
||||
// Lens-extensions api developer's kit
|
||||
export type { LensExtensionRuntimeEnv } from "./lens-runtime";
|
||||
export * from "./lens-main-extension"
|
||||
export * from "./lens-renderer-extension"
|
||||
|
||||
// APIs
|
||||
import * as EventBus from "./core-api/event-bus"
|
||||
import * as Store from "./core-api/stores"
|
||||
import * as Util from "./core-api/utils"
|
||||
import * as Registry from "./core-api/registries"
|
||||
import * as ClusterFeature from "./core-api/cluster-feature"
|
||||
|
||||
export {
|
||||
ClusterFeature,
|
||||
EventBus,
|
||||
Registry,
|
||||
Store,
|
||||
Util
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { broadcastIpc } from "../common/ipc"
|
||||
import { observable, reaction, toJS, } from "mobx"
|
||||
import logger from "../main/logger"
|
||||
import { app, ipcRenderer, remote } from "electron"
|
||||
import { appPreferenceRegistry, kubeObjectMenuRegistry, menuRegistry, pageRegistry, statusBarRegistry } from "./registries";
|
||||
import { appPreferenceRegistry, kubeObjectMenuRegistry, menuRegistry, pageRegistry, statusBarRegistry, clusterFeatureRegistry } from "./registries";
|
||||
|
||||
export interface InstalledExtension extends ExtensionModel {
|
||||
manifestPath: string;
|
||||
@@ -46,6 +46,7 @@ export class ExtensionLoader {
|
||||
this.autoloadExtensions((instance: LensRendererExtension) => {
|
||||
instance.registerPages(pageRegistry)
|
||||
instance.registerAppPreferences(appPreferenceRegistry)
|
||||
instance.registerClusterFeatures(clusterFeatureRegistry)
|
||||
instance.registerStatusBarIcon(statusBarRegistry)
|
||||
})
|
||||
}
|
||||
@@ -60,8 +61,8 @@ export class ExtensionLoader {
|
||||
|
||||
protected autoloadExtensions(callback: (instance: LensExtension) => void) {
|
||||
return reaction(() => this.extensions.toJS(), (installedExtensions) => {
|
||||
for (const [id, ext] of installedExtensions) {
|
||||
let instance = this.instances.get(ext.name)
|
||||
for(const [id, ext] of installedExtensions) {
|
||||
let instance = this.instances.get(ext.id)
|
||||
if (!instance) {
|
||||
const extensionModule = this.requireExtension(ext)
|
||||
if (!extensionModule) {
|
||||
@@ -69,9 +70,12 @@ export class ExtensionLoader {
|
||||
}
|
||||
const LensExtensionClass = extensionModule.default;
|
||||
instance = new LensExtensionClass({ ...ext.manifest, manifestPath: ext.manifestPath, id: ext.manifestPath }, ext.manifest);
|
||||
instance.enable();
|
||||
callback(instance)
|
||||
this.instances.set(ext.name, instance)
|
||||
try {
|
||||
instance.enable()
|
||||
callback(instance)
|
||||
} finally {
|
||||
this.instances.set(ext.id, instance)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LensExtension } from "./lens-extension"
|
||||
import type { PageRegistry, AppPreferenceRegistry, StatusBarRegistry, KubeObjectMenuRegistry } from "./registries"
|
||||
import type { PageRegistry, AppPreferenceRegistry, StatusBarRegistry, KubeObjectMenuRegistry, ClusterFeatureRegistry } from "./registries"
|
||||
|
||||
export class LensRendererExtension extends LensExtension {
|
||||
registerPages(registry: PageRegistry) {
|
||||
@@ -10,6 +10,10 @@ export class LensRendererExtension extends LensExtension {
|
||||
return
|
||||
}
|
||||
|
||||
registerClusterFeatures(registry: ClusterFeatureRegistry) {
|
||||
return
|
||||
}
|
||||
|
||||
registerStatusBarIcon(registry: StatusBarRegistry) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { BaseRegistry } from "./base-registry";
|
||||
import { ClusterFeature } from "../cluster-feature";
|
||||
|
||||
export interface ClusterFeatureComponents {
|
||||
Description: React.ComponentType<any>;
|
||||
}
|
||||
|
||||
export interface ClusterFeatureRegistration {
|
||||
title: string;
|
||||
components: ClusterFeatureComponents
|
||||
feature: ClusterFeature
|
||||
}
|
||||
|
||||
export class ClusterFeatureRegistry extends BaseRegistry<ClusterFeatureRegistration> {}
|
||||
|
||||
export const clusterFeatureRegistry = new ClusterFeatureRegistry()
|
||||
@@ -5,3 +5,4 @@ export * from "./menu-registry"
|
||||
export * from "./app-preference-registry"
|
||||
export * from "./status-bar-registry"
|
||||
export * from "./kube-object-menu-registry";
|
||||
export * from "./cluster-feature-registry"
|
||||
@@ -1,6 +1,5 @@
|
||||
|
||||
export { apiManager } from "../../renderer/api/api-manager";
|
||||
export { KubeApi } from "../../renderer/api/kube-api";
|
||||
export { KubeApi, forCluster, IKubeApiCluster } from "../../renderer/api/kube-api";
|
||||
export { KubeObject } from "../../renderer/api/kube-object";
|
||||
export { Pod, podsApi, IPodContainer, IPodContainerStatus } from "../../renderer/api/endpoints";
|
||||
export { Node, nodesApi } from "../../renderer/api/endpoints";
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { Feature, FeatureStatus } from "../main/feature"
|
||||
import {KubeConfig, AppsV1Api, RbacAuthorizationV1Api} from "@kubernetes/client-node"
|
||||
import semver from "semver"
|
||||
import { Cluster } from "../main/cluster";
|
||||
import * as k8s from "@kubernetes/client-node"
|
||||
|
||||
export interface MetricsConfiguration {
|
||||
// Placeholder for Metrics config structure
|
||||
persistence: {
|
||||
enabled: boolean;
|
||||
storageClass: string;
|
||||
size: string;
|
||||
};
|
||||
nodeExporter: {
|
||||
enabled: boolean;
|
||||
};
|
||||
kubeStateMetrics: {
|
||||
enabled: boolean;
|
||||
};
|
||||
retention: {
|
||||
time: string;
|
||||
size: string;
|
||||
};
|
||||
alertManagers: string[];
|
||||
replicas: number;
|
||||
storageClass: string;
|
||||
}
|
||||
|
||||
export class MetricsFeature extends Feature {
|
||||
static id = 'metrics'
|
||||
name = MetricsFeature.id;
|
||||
latestVersion = "v2.17.2-lens1"
|
||||
|
||||
config: MetricsConfiguration = {
|
||||
persistence: {
|
||||
enabled: false,
|
||||
storageClass: null,
|
||||
size: "20G",
|
||||
},
|
||||
nodeExporter: {
|
||||
enabled: true,
|
||||
},
|
||||
retention: {
|
||||
time: "2d",
|
||||
size: "5GB",
|
||||
},
|
||||
kubeStateMetrics: {
|
||||
enabled: true,
|
||||
},
|
||||
alertManagers: null,
|
||||
replicas: 1,
|
||||
storageClass: null,
|
||||
};
|
||||
|
||||
async install(cluster: Cluster): Promise<void> {
|
||||
// Check if there are storageclasses
|
||||
const storageClient = cluster.getProxyKubeconfig().makeApiClient(k8s.StorageV1Api)
|
||||
const scs = await storageClient.listStorageClass();
|
||||
|
||||
this.config.persistence.enabled = scs.body.items.some(sc => (
|
||||
sc.metadata?.annotations?.['storageclass.kubernetes.io/is-default-class'] === 'true' ||
|
||||
sc.metadata?.annotations?.['storageclass.beta.kubernetes.io/is-default-class'] === 'true'
|
||||
));
|
||||
|
||||
return super.install(cluster)
|
||||
}
|
||||
|
||||
async upgrade(cluster: Cluster): Promise<void> {
|
||||
return this.install(cluster)
|
||||
}
|
||||
|
||||
async featureStatus(kc: KubeConfig): Promise<FeatureStatus> {
|
||||
const client = kc.makeApiClient(AppsV1Api)
|
||||
const status: FeatureStatus = {
|
||||
currentVersion: null,
|
||||
installed: false,
|
||||
latestVersion: this.latestVersion,
|
||||
canUpgrade: false, // Dunno yet
|
||||
};
|
||||
|
||||
try {
|
||||
const prometheus = (await client.readNamespacedStatefulSet('prometheus', 'lens-metrics')).body;
|
||||
status.installed = true;
|
||||
status.currentVersion = prometheus.spec.template.spec.containers[0].image.split(":")[1];
|
||||
status.canUpgrade = semver.lt(status.currentVersion, this.latestVersion, true);
|
||||
} catch {
|
||||
// ignore error
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
async uninstall(cluster: Cluster): Promise<void> {
|
||||
const rbacClient = cluster.getProxyKubeconfig().makeApiClient(RbacAuthorizationV1Api)
|
||||
|
||||
await this.deleteNamespace(cluster.getProxyKubeconfig(), "lens-metrics")
|
||||
await rbacClient.deleteClusterRole("lens-prometheus");
|
||||
await rbacClient.deleteClusterRoleBinding("lens-prometheus");
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Feature, FeatureStatus } from "../main/feature"
|
||||
import {KubeConfig, RbacAuthorizationV1Api} from "@kubernetes/client-node"
|
||||
import { Cluster } from "../main/cluster"
|
||||
|
||||
export class UserModeFeature extends Feature {
|
||||
static id = 'user-mode'
|
||||
name = UserModeFeature.id;
|
||||
latestVersion = "v2.0.0"
|
||||
|
||||
async install(cluster: Cluster): Promise<void> {
|
||||
return super.install(cluster)
|
||||
}
|
||||
|
||||
async upgrade(cluster: Cluster): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
async featureStatus(kc: KubeConfig): Promise<FeatureStatus> {
|
||||
const client = kc.makeApiClient(RbacAuthorizationV1Api)
|
||||
const status: FeatureStatus = {
|
||||
currentVersion: null,
|
||||
installed: false,
|
||||
latestVersion: this.latestVersion,
|
||||
canUpgrade: false, // Dunno yet
|
||||
};
|
||||
|
||||
try {
|
||||
await client.readClusterRoleBinding("lens-user")
|
||||
status.installed = true;
|
||||
status.currentVersion = this.latestVersion;
|
||||
status.canUpgrade = false;
|
||||
} catch {
|
||||
// ignore error
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
async uninstall(cluster: Cluster): Promise<void> {
|
||||
const rbacClient = cluster.getProxyKubeconfig().makeApiClient(RbacAuthorizationV1Api)
|
||||
await rbacClient.deleteClusterRole("lens-user");
|
||||
await rbacClient.deleteClusterRoleBinding("lens-user");
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: lens-user
|
||||
rules:
|
||||
- verbs:
|
||||
- list
|
||||
apiGroups:
|
||||
- ''
|
||||
resources:
|
||||
- namespaces
|
||||
@@ -1,12 +0,0 @@
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: lens-user
|
||||
subjects:
|
||||
- kind: Group
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
name: 'system:authenticated'
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: lens-user
|
||||
@@ -49,6 +49,8 @@ export class ClusterManager {
|
||||
// we need to swap path prefix so that request is proxied to kube api
|
||||
req.url = req.url.replace(`/${clusterId}`, apiKubePrefix)
|
||||
}
|
||||
} else if (req.headers["x-cluster-id"]) {
|
||||
cluster = clusterStore.getById(req.headers["x-cluster-id"].toString())
|
||||
} else {
|
||||
const clusterId = getClusterIdFromHost(req.headers.host);
|
||||
cluster = clusterStore.getById(clusterId)
|
||||
|
||||
+1
-23
@@ -1,7 +1,6 @@
|
||||
import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences } from "../common/cluster-store"
|
||||
import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api";
|
||||
import type { WorkspaceId } from "../common/workspace-store";
|
||||
import type { FeatureStatusMap } from "./feature"
|
||||
import { action, computed, observable, reaction, toJS, when } from "mobx";
|
||||
import { apiKubePrefix } from "../common/vars";
|
||||
import { broadcastIpc } from "../common/ipc";
|
||||
@@ -10,7 +9,6 @@ import { AuthorizationV1Api, CoreV1Api, KubeConfig, V1ResourceAttributes } from
|
||||
import { Kubectl } from "./kubectl";
|
||||
import { KubeconfigManager } from "./kubeconfig-manager"
|
||||
import { getNodeWarningConditions, loadConfig, podHasIssues } from "../common/kube-helpers"
|
||||
import { getFeatures, installFeature, uninstallFeature, upgradeFeature } from "./feature-manager";
|
||||
import request, { RequestPromiseOptions } from "request-promise-native"
|
||||
import { apiResources } from "../common/rbac";
|
||||
import logger from "./logger"
|
||||
@@ -47,7 +45,6 @@ export interface ClusterState extends ClusterModel {
|
||||
isAdmin: boolean;
|
||||
allowedNamespaces: string[]
|
||||
allowedResources: string[]
|
||||
features: FeatureStatusMap;
|
||||
}
|
||||
|
||||
export class Cluster implements ClusterModel {
|
||||
@@ -78,7 +75,6 @@ export class Cluster implements ClusterModel {
|
||||
@observable eventCount = 0;
|
||||
@observable preferences: ClusterPreferences = {};
|
||||
@observable metadata: ClusterMetadata = {};
|
||||
@observable features: FeatureStatusMap = {};
|
||||
@observable allowedNamespaces: string[] = [];
|
||||
@observable allowedResources: string[] = [];
|
||||
|
||||
@@ -194,12 +190,7 @@ export class Cluster implements ClusterModel {
|
||||
await this.whenInitialized;
|
||||
await this.refreshConnectionStatus();
|
||||
if (this.accessible) {
|
||||
const [features, isAdmin] = await Promise.all([
|
||||
getFeatures(this),
|
||||
this.isClusterAdmin(),
|
||||
]);
|
||||
this.features = features;
|
||||
this.isAdmin = isAdmin;
|
||||
this.isAdmin = await this.isClusterAdmin();
|
||||
await Promise.all([
|
||||
this.refreshEvents(),
|
||||
this.refreshAllowedResources(),
|
||||
@@ -250,18 +241,6 @@ export class Cluster implements ClusterModel {
|
||||
return this.kubeconfigManager.getPath()
|
||||
}
|
||||
|
||||
async installFeature(name: string, config: any) {
|
||||
return installFeature(name, this, config)
|
||||
}
|
||||
|
||||
async upgradeFeature(name: string, config: any) {
|
||||
return upgradeFeature(name, this, config)
|
||||
}
|
||||
|
||||
async uninstallFeature(name: string) {
|
||||
return uninstallFeature(name, this)
|
||||
}
|
||||
|
||||
protected async k8sRequest<T = any>(path: string, options: RequestPromiseOptions = {}): Promise<T> {
|
||||
const apiUrl = this.kubeProxyUrl + path;
|
||||
return request(apiUrl, {
|
||||
@@ -400,7 +379,6 @@ export class Cluster implements ClusterModel {
|
||||
accessible: this.accessible,
|
||||
failureReason: this.failureReason,
|
||||
isAdmin: this.isAdmin,
|
||||
features: this.features,
|
||||
eventCount: this.eventCount,
|
||||
allowedNamespaces: this.allowedNamespaces,
|
||||
allowedResources: this.allowedResources,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { KubeConfig } from "@kubernetes/client-node"
|
||||
import logger from "./logger";
|
||||
import { Cluster } from "./cluster";
|
||||
import { Feature, FeatureStatusMap, FeatureMap } from "./feature"
|
||||
import { MetricsFeature } from "../features/metrics"
|
||||
import { UserModeFeature } from "../features/user-mode"
|
||||
|
||||
const ALL_FEATURES: Map<string, Feature> = new Map([
|
||||
[MetricsFeature.id, new MetricsFeature(null)],
|
||||
[UserModeFeature.id, new UserModeFeature(null)],
|
||||
]);
|
||||
|
||||
export async function getFeatures(cluster: Cluster): Promise<FeatureStatusMap> {
|
||||
const result: FeatureStatusMap = {};
|
||||
logger.debug(`features for ${cluster.contextName}`);
|
||||
|
||||
for (const [key, feature] of ALL_FEATURES) {
|
||||
logger.debug(`feature ${key}`);
|
||||
logger.debug("getting feature status...");
|
||||
|
||||
const kc = new KubeConfig();
|
||||
kc.loadFromFile(cluster.getProxyKubeconfigPath());
|
||||
|
||||
result[feature.name] = await feature.featureStatus(kc);
|
||||
}
|
||||
|
||||
logger.debug(`getFeatures resolving with features: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
export async function installFeature(name: string, cluster: Cluster, config: any): Promise<void> {
|
||||
// TODO Figure out how to handle config stuff
|
||||
return ALL_FEATURES.get(name).install(cluster)
|
||||
}
|
||||
|
||||
export async function upgradeFeature(name: string, cluster: Cluster, config: any): Promise<void> {
|
||||
// TODO Figure out how to handle config stuff
|
||||
return ALL_FEATURES.get(name).upgrade(cluster)
|
||||
}
|
||||
|
||||
export async function uninstallFeature(name: string, cluster: Cluster): Promise<void> {
|
||||
return ALL_FEATURES.get(name).uninstall(cluster)
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path"
|
||||
import hb from "handlebars"
|
||||
import { ResourceApplier } from "./resource-applier"
|
||||
import { CoreV1Api, KubeConfig, Watch } from "@kubernetes/client-node"
|
||||
import { Cluster } from "./cluster";
|
||||
import logger from "./logger";
|
||||
import { isDevelopment } from "../common/vars";
|
||||
|
||||
export type FeatureStatusMap = Record<string, FeatureStatus>
|
||||
export type FeatureMap = Record<string, Feature>
|
||||
|
||||
export interface FeatureInstallRequest {
|
||||
clusterId: string;
|
||||
name: string;
|
||||
config?: any;
|
||||
}
|
||||
|
||||
export interface FeatureStatus {
|
||||
currentVersion: string;
|
||||
installed: boolean;
|
||||
latestVersion: string;
|
||||
canUpgrade: boolean;
|
||||
}
|
||||
|
||||
export abstract class Feature {
|
||||
public name: string;
|
||||
public latestVersion: string;
|
||||
|
||||
abstract async upgrade(cluster: Cluster): Promise<void>;
|
||||
|
||||
abstract async uninstall(cluster: Cluster): Promise<void>;
|
||||
|
||||
abstract async featureStatus(kc: KubeConfig): Promise<FeatureStatus>;
|
||||
|
||||
constructor(public config: any) {
|
||||
}
|
||||
|
||||
get folderPath() {
|
||||
if (isDevelopment) {
|
||||
return path.resolve(__static, "../src/features", this.name);
|
||||
}
|
||||
return path.resolve(__static, "../features", this.name);
|
||||
}
|
||||
|
||||
async install(cluster: Cluster): Promise<void> {
|
||||
const resources = this.renderTemplates();
|
||||
try {
|
||||
await new ResourceApplier(cluster).kubectlApplyAll(resources);
|
||||
} catch (err) {
|
||||
logger.error("Installing feature error", { err, cluster });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
protected async deleteNamespace(kc: KubeConfig, name: string) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const client = kc.makeApiClient(CoreV1Api)
|
||||
const result = await client.deleteNamespace("lens-metrics", 'false', undefined, undefined, undefined, "Foreground");
|
||||
const nsVersion = result.body.metadata.resourceVersion;
|
||||
const nsWatch = new Watch(kc);
|
||||
const query: Record<string, string> = {
|
||||
resourceVersion: nsVersion,
|
||||
fieldSelector: "metadata.name=lens-metrics",
|
||||
}
|
||||
const req = await nsWatch.watch('/api/v1/namespaces', query,
|
||||
(phase, obj) => {
|
||||
if (phase === 'DELETED') {
|
||||
logger.debug(`namespace ${name} finally gone`)
|
||||
req.abort();
|
||||
resolve()
|
||||
}
|
||||
},
|
||||
(err?: any) => {
|
||||
if (err) reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected renderTemplates(): string[] {
|
||||
const folderPath = this.folderPath;
|
||||
const resources: string[] = [];
|
||||
logger.info(`[FEATURE]: render templates from ${folderPath}`);
|
||||
fs.readdirSync(folderPath).forEach(filename => {
|
||||
const file = path.join(folderPath, filename);
|
||||
const raw = fs.readFileSync(file);
|
||||
if (filename.endsWith('.hb')) {
|
||||
const template = hb.compile(raw.toString());
|
||||
resources.push(template(this.config));
|
||||
} else {
|
||||
resources.push(raw.toString());
|
||||
}
|
||||
});
|
||||
|
||||
return resources;
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,10 @@ import { KubeApi } from "../kube-api";
|
||||
|
||||
export class ClusterRoleBinding extends RoleBinding {
|
||||
static kind = "ClusterRoleBinding"
|
||||
static namespaced = false
|
||||
static apiBase = "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings"
|
||||
}
|
||||
|
||||
export const clusterRoleBindingApi = new KubeApi({
|
||||
kind: ClusterRoleBinding.kind,
|
||||
apiBase: "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings",
|
||||
isNamespaced: false,
|
||||
objectConstructor: ClusterRoleBinding,
|
||||
});
|
||||
@@ -5,11 +5,10 @@ import { KubeApi } from "../kube-api";
|
||||
@autobind()
|
||||
export class ClusterRole extends Role {
|
||||
static kind = "ClusterRole"
|
||||
static namespaced = false
|
||||
static apiBase = "/apis/rbac.authorization.k8s.io/v1/clusterroles"
|
||||
}
|
||||
|
||||
export const clusterRoleApi = new KubeApi({
|
||||
kind: ClusterRole.kind,
|
||||
apiBase: "/apis/rbac.authorization.k8s.io/v1/clusterroles",
|
||||
isNamespaced: false,
|
||||
objectConstructor: ClusterRole,
|
||||
});
|
||||
@@ -3,6 +3,9 @@ import { KubeObject } from "../kube-object";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export class ClusterApi extends KubeApi<Cluster> {
|
||||
static kind = "Cluster"
|
||||
static namespaced = true
|
||||
|
||||
async getMetrics(nodeNames: string[], params?: IMetricsReqParams): Promise<IClusterMetrics> {
|
||||
const nodes = nodeNames.join("|");
|
||||
const opts = { category: "cluster", nodes: nodes }
|
||||
@@ -49,6 +52,7 @@ export interface IClusterMetrics<T = IMetrics> {
|
||||
|
||||
export class Cluster extends KubeObject {
|
||||
static kind = "Cluster";
|
||||
static apiBase = "/apis/cluster.k8s.io/v1alpha1/clusters"
|
||||
|
||||
spec: {
|
||||
clusterNetwork?: {
|
||||
@@ -91,8 +95,5 @@ export class Cluster extends KubeObject {
|
||||
}
|
||||
|
||||
export const clusterApi = new ClusterApi({
|
||||
kind: Cluster.kind,
|
||||
apiBase: "/apis/cluster.k8s.io/v1alpha1/clusters",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Cluster,
|
||||
});
|
||||
@@ -9,6 +9,8 @@ export interface IComponentStatusCondition {
|
||||
|
||||
export class ComponentStatus extends KubeObject {
|
||||
static kind = "ComponentStatus"
|
||||
static namespaced = false
|
||||
static apiBase = "/api/v1/componentstatuses"
|
||||
|
||||
conditions: IComponentStatusCondition[]
|
||||
|
||||
@@ -18,8 +20,5 @@ export class ComponentStatus extends KubeObject {
|
||||
}
|
||||
|
||||
export const componentStatusApi = new KubeApi({
|
||||
kind: ComponentStatus.kind,
|
||||
apiBase: "/api/v1/componentstatuses",
|
||||
isNamespaced: false,
|
||||
objectConstructor: ComponentStatus,
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import { KubeApi } from "../kube-api";
|
||||
@autobind()
|
||||
export class ConfigMap extends KubeObject {
|
||||
static kind = "ConfigMap";
|
||||
static namespaced = true;
|
||||
static apiBase = "/api/v1/configmaps"
|
||||
|
||||
constructor(data: KubeJsonApiData) {
|
||||
super(data);
|
||||
@@ -22,8 +24,5 @@ export class ConfigMap extends KubeObject {
|
||||
}
|
||||
|
||||
export const configMapApi = new KubeApi({
|
||||
kind: ConfigMap.kind,
|
||||
apiBase: "/api/v1/configmaps",
|
||||
isNamespaced: true,
|
||||
objectConstructor: ConfigMap,
|
||||
});
|
||||
@@ -19,6 +19,8 @@ type AdditionalPrinterColumnsV1Beta = AdditionalPrinterColumnsCommon & {
|
||||
|
||||
export class CustomResourceDefinition extends KubeObject {
|
||||
static kind = "CustomResourceDefinition";
|
||||
static namespaced = false;
|
||||
static apiBase = "/apis/apiextensions.k8s.io/v1/customresourcedefinitions"
|
||||
|
||||
spec: {
|
||||
group: string;
|
||||
@@ -145,9 +147,6 @@ export class CustomResourceDefinition extends KubeObject {
|
||||
}
|
||||
|
||||
export const crdApi = new VersionedKubeApi<CustomResourceDefinition>({
|
||||
kind: CustomResourceDefinition.kind,
|
||||
apiBase: "/apis/apiextensions.k8s.io/v1/customresourcedefinitions",
|
||||
isNamespaced: false,
|
||||
objectConstructor: CustomResourceDefinition
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ import { KubeApi } from "../kube-api";
|
||||
@autobind()
|
||||
export class CronJob extends KubeObject {
|
||||
static kind = "CronJob"
|
||||
static namespaced = true
|
||||
static apiBase = "/apis/batch/v1beta1/cronjobs"
|
||||
|
||||
kind: string
|
||||
apiVersion: string
|
||||
@@ -88,8 +90,5 @@ export class CronJob extends KubeObject {
|
||||
}
|
||||
|
||||
export const cronJobApi = new KubeApi({
|
||||
kind: CronJob.kind,
|
||||
apiBase: "/apis/batch/v1beta1/cronjobs",
|
||||
isNamespaced: true,
|
||||
objectConstructor: CronJob,
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import { KubeApi } from "../kube-api";
|
||||
@autobind()
|
||||
export class DaemonSet extends WorkloadKubeObject {
|
||||
static kind = "DaemonSet"
|
||||
static namespaced = true
|
||||
static apiBase = "/apis/apps/v1/daemonsets"
|
||||
|
||||
spec: {
|
||||
selector: {
|
||||
@@ -69,8 +71,5 @@ export class DaemonSet extends WorkloadKubeObject {
|
||||
}
|
||||
|
||||
export const daemonSetApi = new KubeApi({
|
||||
kind: DaemonSet.kind,
|
||||
apiBase: "/apis/apps/v1/daemonsets",
|
||||
isNamespaced: true,
|
||||
objectConstructor: DaemonSet,
|
||||
});
|
||||
@@ -28,6 +28,8 @@ export class DeploymentApi extends KubeApi<Deployment> {
|
||||
@autobind()
|
||||
export class Deployment extends WorkloadKubeObject {
|
||||
static kind = "Deployment"
|
||||
static namespaced = true
|
||||
static apiBase = "/apis/apps/v1/deployments"
|
||||
|
||||
spec: {
|
||||
replicas: number;
|
||||
@@ -164,8 +166,5 @@ export class Deployment extends WorkloadKubeObject {
|
||||
}
|
||||
|
||||
export const deploymentApi = new DeploymentApi({
|
||||
kind: Deployment.kind,
|
||||
apiBase: "/apis/apps/v1/deployments",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Deployment,
|
||||
});
|
||||
@@ -99,6 +99,8 @@ export class EndpointSubset implements IEndpointSubset {
|
||||
@autobind()
|
||||
export class Endpoint extends KubeObject {
|
||||
static kind = "Endpoints"
|
||||
static namespaced = true
|
||||
static apiBase = "/api/v1/endpoints"
|
||||
|
||||
subsets: IEndpointSubset[]
|
||||
|
||||
@@ -118,8 +120,5 @@ export class Endpoint extends KubeObject {
|
||||
}
|
||||
|
||||
export const endpointApi = new KubeApi({
|
||||
kind: Endpoint.kind,
|
||||
apiBase: "/api/v1/endpoints",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Endpoint,
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import { KubeApi } from "../kube-api";
|
||||
@autobind()
|
||||
export class KubeEvent extends KubeObject {
|
||||
static kind = "Event"
|
||||
static namespaced = true
|
||||
static apiBase = "/api/v1/events"
|
||||
|
||||
involvedObject: {
|
||||
kind: string;
|
||||
@@ -52,8 +54,5 @@ export class KubeEvent extends KubeObject {
|
||||
}
|
||||
|
||||
export const eventApi = new KubeApi({
|
||||
kind: KubeEvent.kind,
|
||||
apiBase: "/api/v1/events",
|
||||
isNamespaced: true,
|
||||
objectConstructor: KubeEvent,
|
||||
})
|
||||
@@ -40,6 +40,8 @@ export interface IHpaMetric {
|
||||
|
||||
export class HorizontalPodAutoscaler extends KubeObject {
|
||||
static kind = "HorizontalPodAutoscaler";
|
||||
static namespaced = true;
|
||||
static apiBase = "/apis/autoscaling/v2beta1/horizontalpodautoscalers"
|
||||
|
||||
spec: {
|
||||
scaleTargetRef: {
|
||||
@@ -133,8 +135,5 @@ export class HorizontalPodAutoscaler extends KubeObject {
|
||||
}
|
||||
|
||||
export const hpaApi = new KubeApi({
|
||||
kind: HorizontalPodAutoscaler.kind,
|
||||
apiBase: "/apis/autoscaling/v2beta1/horizontalpodautoscalers",
|
||||
isNamespaced: true,
|
||||
objectConstructor: HorizontalPodAutoscaler,
|
||||
});
|
||||
@@ -32,6 +32,8 @@ export interface ILoadBalancerIngress {
|
||||
@autobind()
|
||||
export class Ingress extends KubeObject {
|
||||
static kind = "Ingress"
|
||||
static namespaced = true
|
||||
static apiBase = "/apis/extensions/v1beta1/ingresses"
|
||||
|
||||
spec: {
|
||||
tls: {
|
||||
@@ -110,8 +112,5 @@ export class Ingress extends KubeObject {
|
||||
}
|
||||
|
||||
export const ingressApi = new IngressApi({
|
||||
kind: Ingress.kind,
|
||||
apiBase: "/apis/extensions/v1beta1/ingresses",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Ingress,
|
||||
});
|
||||
@@ -8,6 +8,8 @@ import { JsonApiParams } from "../json-api";
|
||||
@autobind()
|
||||
export class Job extends WorkloadKubeObject {
|
||||
static kind = "Job"
|
||||
static namespaced = true
|
||||
static apiBase = "/apis/batch/v1/jobs"
|
||||
|
||||
spec: {
|
||||
parallelism?: number;
|
||||
@@ -102,8 +104,5 @@ export class Job extends WorkloadKubeObject {
|
||||
}
|
||||
|
||||
export const jobApi = new KubeApi({
|
||||
kind: Job.kind,
|
||||
apiBase: "/apis/batch/v1/jobs",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Job,
|
||||
});
|
||||
@@ -10,6 +10,8 @@ export enum NamespaceStatus {
|
||||
@autobind()
|
||||
export class Namespace extends KubeObject {
|
||||
static kind = "Namespace";
|
||||
static namespaced = false;
|
||||
static apiBase = "/api/v1/namespaces";
|
||||
|
||||
status?: {
|
||||
phase: string;
|
||||
@@ -21,8 +23,5 @@ export class Namespace extends KubeObject {
|
||||
}
|
||||
|
||||
export const namespacesApi = new KubeApi({
|
||||
kind: Namespace.kind,
|
||||
apiBase: "/api/v1/namespaces",
|
||||
isNamespaced: false,
|
||||
objectConstructor: Namespace,
|
||||
});
|
||||
@@ -38,6 +38,8 @@ export interface IPolicyEgress {
|
||||
@autobind()
|
||||
export class NetworkPolicy extends KubeObject {
|
||||
static kind = "NetworkPolicy"
|
||||
static namespaced = true
|
||||
static apiBase = "/apis/networking.k8s.io/v1/networkpolicies"
|
||||
|
||||
spec: {
|
||||
podSelector: {
|
||||
@@ -65,8 +67,5 @@ export class NetworkPolicy extends KubeObject {
|
||||
}
|
||||
|
||||
export const networkPolicyApi = new KubeApi({
|
||||
kind: NetworkPolicy.kind,
|
||||
apiBase: "/apis/networking.k8s.io/v1/networkpolicies",
|
||||
isNamespaced: true,
|
||||
objectConstructor: NetworkPolicy,
|
||||
});
|
||||
@@ -31,6 +31,8 @@ export interface INodeMetrics<T = IMetrics> {
|
||||
@autobind()
|
||||
export class Node extends KubeObject {
|
||||
static kind = "Node"
|
||||
static namespaced = false
|
||||
static apiBase = "/api/v1/nodes"
|
||||
|
||||
spec: {
|
||||
podCIDR: string;
|
||||
@@ -156,8 +158,5 @@ export class Node extends KubeObject {
|
||||
}
|
||||
|
||||
export const nodesApi = new NodesApi({
|
||||
kind: Node.kind,
|
||||
apiBase: "/api/v1/nodes",
|
||||
isNamespaced: false,
|
||||
objectConstructor: Node,
|
||||
});
|
||||
@@ -24,6 +24,8 @@ export interface IPvcMetrics<T = IMetrics> {
|
||||
@autobind()
|
||||
export class PersistentVolumeClaim extends KubeObject {
|
||||
static kind = "PersistentVolumeClaim"
|
||||
static namespaced = true
|
||||
static apiBase = "/api/v1/persistentvolumeclaims"
|
||||
|
||||
spec: {
|
||||
accessModes: string[];
|
||||
@@ -81,8 +83,5 @@ export class PersistentVolumeClaim extends KubeObject {
|
||||
}
|
||||
|
||||
export const pvcApi = new PersistentVolumeClaimsApi({
|
||||
kind: PersistentVolumeClaim.kind,
|
||||
apiBase: "/api/v1/persistentvolumeclaims",
|
||||
isNamespaced: true,
|
||||
objectConstructor: PersistentVolumeClaim,
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import { KubeApi } from "../kube-api";
|
||||
@autobind()
|
||||
export class PersistentVolume extends KubeObject {
|
||||
static kind = "PersistentVolume"
|
||||
static namespaced = false
|
||||
static apiBase = "/api/v1/persistentvolumes"
|
||||
|
||||
spec: {
|
||||
capacity: {
|
||||
@@ -64,8 +66,5 @@ export class PersistentVolume extends KubeObject {
|
||||
}
|
||||
|
||||
export const persistentVolumeApi = new KubeApi({
|
||||
kind: PersistentVolume.kind,
|
||||
apiBase: "/api/v1/persistentvolumes",
|
||||
isNamespaced: false,
|
||||
objectConstructor: PersistentVolume,
|
||||
});
|
||||
@@ -2,6 +2,10 @@ import { KubeObject } from "../kube-object";
|
||||
import { KubeApi } from "../kube-api";
|
||||
|
||||
export class PodMetrics extends KubeObject {
|
||||
static kind = "Pod"
|
||||
static namespaced = true
|
||||
static apiBase = "/apis/metrics.k8s.io/v1beta1/pods"
|
||||
|
||||
timestamp: string
|
||||
window: string
|
||||
containers: {
|
||||
@@ -14,8 +18,5 @@ export class PodMetrics extends KubeObject {
|
||||
}
|
||||
|
||||
export const podMetricsApi = new KubeApi({
|
||||
kind: PodMetrics.kind,
|
||||
apiBase: "/apis/metrics.k8s.io/v1beta1/pods",
|
||||
isNamespaced: true,
|
||||
objectConstructor: PodMetrics,
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import { KubeApi } from "../kube-api";
|
||||
@autobind()
|
||||
export class PodDisruptionBudget extends KubeObject {
|
||||
static kind = "PodDisruptionBudget";
|
||||
static namespaced = true;
|
||||
static apiBase = "/apis/policy/v1beta1/poddisruptionbudgets";
|
||||
|
||||
spec: {
|
||||
minAvailable: string;
|
||||
@@ -42,8 +44,5 @@ export class PodDisruptionBudget extends KubeObject {
|
||||
}
|
||||
|
||||
export const pdbApi = new KubeApi({
|
||||
kind: PodDisruptionBudget.kind,
|
||||
apiBase: "/apis/policy/v1beta1/poddisruptionbudgets",
|
||||
isNamespaced: true,
|
||||
objectConstructor: PodDisruptionBudget,
|
||||
});
|
||||
@@ -163,6 +163,8 @@ export interface IPodContainerStatus {
|
||||
@autobind()
|
||||
export class Pod extends WorkloadKubeObject {
|
||||
static kind = "Pod"
|
||||
static namespaced = true
|
||||
static apiBase = "/api/v1/pods"
|
||||
|
||||
spec: {
|
||||
volumes?: {
|
||||
@@ -419,8 +421,5 @@ export class Pod extends WorkloadKubeObject {
|
||||
}
|
||||
|
||||
export const podsApi = new PodsApi({
|
||||
kind: Pod.kind,
|
||||
apiBase: "/api/v1/pods",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Pod,
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import { KubeApi } from "../kube-api";
|
||||
@autobind()
|
||||
export class PodSecurityPolicy extends KubeObject {
|
||||
static kind = "PodSecurityPolicy"
|
||||
static namespaced = false
|
||||
static apiBase = "/apis/policy/v1beta1/podsecuritypolicies"
|
||||
|
||||
spec: {
|
||||
allowPrivilegeEscalation?: boolean;
|
||||
@@ -87,8 +89,5 @@ export class PodSecurityPolicy extends KubeObject {
|
||||
}
|
||||
|
||||
export const pspApi = new KubeApi({
|
||||
kind: PodSecurityPolicy.kind,
|
||||
apiBase: "/apis/policy/v1beta1/podsecuritypolicies",
|
||||
isNamespaced: false,
|
||||
objectConstructor: PodSecurityPolicy,
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import { KubeApi } from "../kube-api";
|
||||
@autobind()
|
||||
export class ReplicaSet extends WorkloadKubeObject {
|
||||
static kind = "ReplicaSet"
|
||||
static namespaced = true
|
||||
static apiBase = "/apis/apps/v1/replicasets"
|
||||
|
||||
spec: {
|
||||
replicas?: number;
|
||||
@@ -51,8 +53,5 @@ export class ReplicaSet extends WorkloadKubeObject {
|
||||
}
|
||||
|
||||
export const replicaSetApi = new KubeApi({
|
||||
kind: ReplicaSet.kind,
|
||||
apiBase: "/apis/apps/v1/replicasets",
|
||||
isNamespaced: true,
|
||||
objectConstructor: ReplicaSet,
|
||||
});
|
||||
@@ -32,6 +32,8 @@ export interface IResourceQuotaValues {
|
||||
|
||||
export class ResourceQuota extends KubeObject {
|
||||
static kind = "ResourceQuota"
|
||||
static namespaced = true
|
||||
static apiBase = "/api/v1/resourcequotas"
|
||||
|
||||
constructor(data: KubeJsonApiData) {
|
||||
super(data);
|
||||
@@ -61,8 +63,5 @@ export class ResourceQuota extends KubeObject {
|
||||
}
|
||||
|
||||
export const resourceQuotaApi = new KubeApi({
|
||||
kind: ResourceQuota.kind,
|
||||
apiBase: "/api/v1/resourcequotas",
|
||||
isNamespaced: true,
|
||||
objectConstructor: ResourceQuota,
|
||||
});
|
||||
@@ -12,6 +12,8 @@ export interface IRoleBindingSubject {
|
||||
@autobind()
|
||||
export class RoleBinding extends KubeObject {
|
||||
static kind = "RoleBinding"
|
||||
static namespaced = true
|
||||
static apiBase = "/apis/rbac.authorization.k8s.io/v1/rolebindings"
|
||||
|
||||
subjects?: IRoleBindingSubject[]
|
||||
roleRef: {
|
||||
@@ -30,8 +32,5 @@ export class RoleBinding extends KubeObject {
|
||||
}
|
||||
|
||||
export const roleBindingApi = new KubeApi({
|
||||
kind: RoleBinding.kind,
|
||||
apiBase: "/apis/rbac.authorization.k8s.io/v1/rolebindings",
|
||||
isNamespaced: true,
|
||||
objectConstructor: RoleBinding,
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import { KubeApi } from "../kube-api";
|
||||
|
||||
export class Role extends KubeObject {
|
||||
static kind = "Role"
|
||||
static namespaced = true
|
||||
static apiBase = "/apis/rbac.authorization.k8s.io/v1/roles"
|
||||
|
||||
rules: {
|
||||
verbs: string[];
|
||||
@@ -17,8 +19,5 @@ export class Role extends KubeObject {
|
||||
}
|
||||
|
||||
export const roleApi = new KubeApi({
|
||||
kind: Role.kind,
|
||||
apiBase: "/apis/rbac.authorization.k8s.io/v1/roles",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Role,
|
||||
});
|
||||
@@ -22,6 +22,8 @@ export interface ISecretRef {
|
||||
@autobind()
|
||||
export class Secret extends KubeObject {
|
||||
static kind = "Secret"
|
||||
static namespaced = true
|
||||
static apiBase = "/api/v1/secrets"
|
||||
|
||||
type: SecretType;
|
||||
data: {
|
||||
@@ -44,8 +46,5 @@ export class Secret extends KubeObject {
|
||||
}
|
||||
|
||||
export const secretsApi = new KubeApi({
|
||||
kind: Secret.kind,
|
||||
apiBase: "/api/v1/secrets",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Secret,
|
||||
});
|
||||
@@ -22,6 +22,8 @@ export interface ISelfSubjectReviewRule {
|
||||
|
||||
export class SelfSubjectRulesReview extends KubeObject {
|
||||
static kind = "SelfSubjectRulesReview"
|
||||
static namespaced = false
|
||||
static apiBase = "/apis/authorization.k8s.io/v1/selfsubjectrulesreviews"
|
||||
|
||||
spec: {
|
||||
// todo: add more types from api docs
|
||||
@@ -61,8 +63,5 @@ export class SelfSubjectRulesReview extends KubeObject {
|
||||
}
|
||||
|
||||
export const selfSubjectRulesReviewApi = new SelfSubjectRulesReviewApi({
|
||||
kind: SelfSubjectRulesReview.kind,
|
||||
apiBase: "/apis/authorization.k8s.io/v1/selfsubjectrulesreviews",
|
||||
isNamespaced: false,
|
||||
objectConstructor: SelfSubjectRulesReview,
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import { KubeApi } from "../kube-api";
|
||||
@autobind()
|
||||
export class ServiceAccount extends KubeObject {
|
||||
static kind = "ServiceAccount";
|
||||
static namespaced = true;
|
||||
static apiBase = "/api/v1/serviceaccounts"
|
||||
|
||||
secrets?: {
|
||||
name: string;
|
||||
@@ -23,8 +25,5 @@ export class ServiceAccount extends KubeObject {
|
||||
}
|
||||
|
||||
export const serviceAccountsApi = new KubeApi<ServiceAccount>({
|
||||
kind: ServiceAccount.kind,
|
||||
apiBase: "/api/v1/serviceaccounts",
|
||||
isNamespaced: true,
|
||||
objectConstructor: ServiceAccount,
|
||||
});
|
||||
@@ -32,6 +32,8 @@ export class ServicePort implements IServicePort {
|
||||
@autobind()
|
||||
export class Service extends KubeObject {
|
||||
static kind = "Service"
|
||||
static namespaced = true
|
||||
static apiBase = "/api/v1/services"
|
||||
|
||||
spec: {
|
||||
type: string;
|
||||
@@ -93,8 +95,5 @@ export class Service extends KubeObject {
|
||||
}
|
||||
|
||||
export const serviceApi = new KubeApi({
|
||||
kind: Service.kind,
|
||||
apiBase: "/api/v1/services",
|
||||
isNamespaced: true,
|
||||
objectConstructor: Service,
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import { KubeApi } from "../kube-api";
|
||||
@autobind()
|
||||
export class StatefulSet extends WorkloadKubeObject {
|
||||
static kind = "StatefulSet"
|
||||
static namespaced = true
|
||||
static apiBase = "/apis/apps/v1/statefulsets"
|
||||
|
||||
spec: {
|
||||
serviceName: string;
|
||||
@@ -77,8 +79,5 @@ export class StatefulSet extends WorkloadKubeObject {
|
||||
}
|
||||
|
||||
export const statefulSetApi = new KubeApi({
|
||||
kind: StatefulSet.kind,
|
||||
apiBase: "/apis/apps/v1/statefulsets",
|
||||
isNamespaced: true,
|
||||
objectConstructor: StatefulSet,
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import { KubeApi } from "../kube-api";
|
||||
@autobind()
|
||||
export class StorageClass extends KubeObject {
|
||||
static kind = "StorageClass"
|
||||
static namespaced = false
|
||||
static apiBase = "/apis/storage.k8s.io/v1/storageclasses"
|
||||
|
||||
provisioner: string; // e.g. "storage.k8s.io/v1"
|
||||
mountOptions?: string[];
|
||||
@@ -32,8 +34,5 @@ export class StorageClass extends KubeObject {
|
||||
}
|
||||
|
||||
export const storageClassApi = new KubeApi({
|
||||
kind: StorageClass.kind,
|
||||
apiBase: "/apis/storage.k8s.io/v1/storageclasses",
|
||||
isNamespaced: false,
|
||||
objectConstructor: StorageClass,
|
||||
});
|
||||
@@ -8,13 +8,15 @@ import { apiKube } from "./index";
|
||||
import { kubeWatchApi } from "./kube-watch-api";
|
||||
import { apiManager } from "./api-manager";
|
||||
import { createKubeApiURL, parseKubeApi } from "./kube-api-parse";
|
||||
import { apiKubePrefix, isDevelopment } from "../../common/vars";
|
||||
import * as URL from "url"
|
||||
|
||||
export interface IKubeApiOptions<T extends KubeObject> {
|
||||
kind: string; // resource type within api-group, e.g. "Namespace"
|
||||
apiBase: string; // base api-path for listing all resources, e.g. "/api/v1/pods"
|
||||
isNamespaced: boolean;
|
||||
apiBase?: string; // base api-path for listing all resources, e.g. "/api/v1/pods"
|
||||
objectConstructor?: IKubeObjectConstructor<T>;
|
||||
request?: KubeJsonApi;
|
||||
isNamespaced?: boolean;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
export interface IKubeApiQueryParams {
|
||||
@@ -25,6 +27,25 @@ export interface IKubeApiQueryParams {
|
||||
continue?: string; // might be used with ?limit from second request
|
||||
}
|
||||
|
||||
export interface IKubeApiCluster {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function forCluster<T extends KubeObject>(cluster: IKubeApiCluster, kubeClass: IKubeObjectConstructor<T>): KubeApi<T> {
|
||||
const request = new KubeJsonApi({
|
||||
apiBase: apiKubePrefix,
|
||||
debug: isDevelopment,
|
||||
}, {
|
||||
headers: {
|
||||
"X-Cluster-ID": cluster.id
|
||||
}
|
||||
});
|
||||
return new KubeApi({
|
||||
objectConstructor: kubeClass,
|
||||
request: request
|
||||
})
|
||||
}
|
||||
|
||||
export class KubeApi<T extends KubeObject = any> {
|
||||
static parseApi = parseKubeApi;
|
||||
|
||||
@@ -48,11 +69,14 @@ export class KubeApi<T extends KubeObject = any> {
|
||||
|
||||
constructor(protected options: IKubeApiOptions<T>) {
|
||||
const {
|
||||
kind,
|
||||
isNamespaced = false,
|
||||
objectConstructor = KubeObject as IKubeObjectConstructor,
|
||||
request = apiKube
|
||||
request = apiKube,
|
||||
kind = options.objectConstructor?.kind,
|
||||
isNamespaced = options.objectConstructor?.namespaced
|
||||
} = options || {};
|
||||
if (!options.apiBase) {
|
||||
options.apiBase = objectConstructor.apiBase
|
||||
}
|
||||
const { apiBase, apiPrefix, apiGroup, apiVersion, apiVersionWithGroup, resource } = KubeApi.parseApi(options.apiBase);
|
||||
|
||||
this.kind = kind;
|
||||
|
||||
@@ -10,6 +10,8 @@ import { resourceApplierApi } from "./endpoints/resource-applier.api";
|
||||
|
||||
export type IKubeObjectConstructor<T extends KubeObject = any> = (new (data: KubeJsonApiData | any) => T) & {
|
||||
kind?: string;
|
||||
namespaced?: boolean;
|
||||
apiBase?: string;
|
||||
};
|
||||
|
||||
export interface IKubeObjectMetadata {
|
||||
@@ -43,6 +45,7 @@ export type IKubeMetaField = keyof IKubeObjectMetadata;
|
||||
@autobind()
|
||||
export class KubeObject implements ItemObject {
|
||||
static readonly kind: string;
|
||||
static readonly namespaced: boolean;
|
||||
|
||||
static create(data: any) {
|
||||
return new KubeObject(data);
|
||||
|
||||
@@ -1,72 +1,87 @@
|
||||
import React from "react";
|
||||
import { observable, reaction, comparer } from "mobx";
|
||||
import { observer, disposeOnUnmount } from "mobx-react";
|
||||
import { clusterIpc } from "../../../../common/cluster-ipc";
|
||||
import { Cluster } from "../../../../main/cluster";
|
||||
import { Button } from "../../button";
|
||||
import { Notifications } from "../../notifications";
|
||||
import { Spinner } from "../../spinner";
|
||||
import { ClusterFeature } from "../../../../extensions/cluster-feature";
|
||||
import { interval } from "../../../utils";
|
||||
|
||||
interface Props {
|
||||
cluster: Cluster
|
||||
feature: string
|
||||
feature: ClusterFeature
|
||||
}
|
||||
|
||||
@observer
|
||||
export class InstallFeature extends React.Component<Props> {
|
||||
@observable loading = false;
|
||||
@observable message = "";
|
||||
|
||||
componentDidMount() {
|
||||
const feature = this.props.feature
|
||||
const cluster = this.props.cluster
|
||||
const statusUpdate = interval(20, () => {
|
||||
feature.updateStatus(cluster)
|
||||
})
|
||||
statusUpdate.start(true)
|
||||
|
||||
disposeOnUnmount(this, () => {
|
||||
statusUpdate.stop()
|
||||
})
|
||||
|
||||
disposeOnUnmount(this,
|
||||
reaction(() => this.props.cluster.features[this.props.feature], () => {
|
||||
reaction(() => feature.status.installed, () => {
|
||||
this.loading = false;
|
||||
this.message = ""
|
||||
}, { equals: comparer.structural })
|
||||
);
|
||||
}
|
||||
|
||||
getActionButtons() {
|
||||
const { cluster, feature } = this.props;
|
||||
const features = cluster.features[feature];
|
||||
const disabled = !cluster.isAdmin || this.loading;
|
||||
const loadingIcon = this.loading ? <Spinner/> : null;
|
||||
if (!features) return null;
|
||||
return (
|
||||
<div className="flex gaps align-center">
|
||||
{features.canUpgrade &&
|
||||
{feature.status.canUpgrade &&
|
||||
<Button
|
||||
primary
|
||||
disabled={disabled}
|
||||
onClick={this.runAction(() =>
|
||||
clusterIpc.upgradeFeature.invokeFromRenderer(cluster.id, feature))
|
||||
}
|
||||
feature.upgrade(cluster)
|
||||
)}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
}
|
||||
{features.installed &&
|
||||
{feature.status.installed &&
|
||||
<Button
|
||||
accent
|
||||
disabled={disabled}
|
||||
onClick={this.runAction(() =>
|
||||
clusterIpc.uninstallFeature.invokeFromRenderer(cluster.id, feature))
|
||||
}
|
||||
onClick={this.runAction(async () => {
|
||||
this.message = "Uninstalling feature ..."
|
||||
feature.uninstall(cluster)
|
||||
})}
|
||||
>
|
||||
Uninstall
|
||||
</Button>
|
||||
}
|
||||
{!features.installed && !features.canUpgrade &&
|
||||
{!feature.status.installed && !feature.status.canUpgrade &&
|
||||
<Button
|
||||
primary
|
||||
disabled={disabled}
|
||||
onClick={this.runAction(() =>
|
||||
clusterIpc.installFeature.invokeFromRenderer(cluster.id, feature))
|
||||
}
|
||||
onClick={this.runAction(async () =>{
|
||||
this.message = "Installing feature ..."
|
||||
feature.install(cluster)
|
||||
})}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
}
|
||||
{loadingIcon}
|
||||
{!cluster.isAdmin && <span className='admin-note'>Actions can only be performed by admins.</span>}
|
||||
{cluster.isAdmin && this.loading && this.message !== "" && <span className='admin-note'>{this.message}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import React from "react";
|
||||
import { Cluster } from "../../../main/cluster";
|
||||
import { InstallFeature } from "./components/install-feature";
|
||||
import { SubTitle } from "../layout/sub-title";
|
||||
import { MetricsFeature } from "../../../features/metrics";
|
||||
import { UserModeFeature } from "../../../features/user-mode";
|
||||
import { clusterFeatureRegistry } from "../../../extensions/registries/cluster-feature-registry";
|
||||
|
||||
interface Props {
|
||||
cluster: Cluster;
|
||||
@@ -12,30 +11,19 @@ interface Props {
|
||||
export class Features extends React.Component<Props> {
|
||||
render() {
|
||||
const { cluster } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Features</h2>
|
||||
<InstallFeature cluster={cluster} feature={MetricsFeature.id}>
|
||||
<>
|
||||
<SubTitle title="Metrics"/>
|
||||
<p>
|
||||
Enable timeseries data visualization (Prometheus stack) for your cluster.
|
||||
Install this only if you don't have existing Prometheus stack installed.
|
||||
You can see preview of manifests{" "}
|
||||
<a href="https://github.com/lensapp/lens/tree/master/src/features/metrics" target="_blank">here</a>.
|
||||
</p>
|
||||
</>
|
||||
</InstallFeature>
|
||||
<InstallFeature cluster={cluster} feature={UserModeFeature.id}>
|
||||
<>
|
||||
<SubTitle title="User Mode"/>
|
||||
<p>
|
||||
User Mode feature enables non-admin users to see namespaces they have access to.{" "}
|
||||
This is achieved by configuring RBAC rules so that every authenticated user is granted to list namespaces.
|
||||
</p>
|
||||
</>
|
||||
</InstallFeature>
|
||||
{ clusterFeatureRegistry.getItems().map((f) => {
|
||||
return (
|
||||
<InstallFeature cluster={cluster} feature={f.feature}>
|
||||
<>
|
||||
<SubTitle title={f.title}/>
|
||||
<p><f.components.Description /></p>
|
||||
</>
|
||||
</InstallFeature>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user