Lens app source code (#119)

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen authored and GitHub committed 2020-03-15 09:52:02 +02:00
1 parent 936cbd53d4
commit 1d0815abd2
797 files changed
+73714 -215

No files matched your search

View File
Whitespace-only changes.
+14
View File
@@ -0,0 +1,14 @@
import { app, remote } from "electron"
/**
*
* @returns app version correctly regardless of dev/prod mode and main/renderer differences
*/
export function getAppVersion(): string {
// app is undefined when running in renderer
let version = (app || remote.app).getVersion()
if(process.env.NODE_ENV === 'development') {
version = require("../../package.json").version
}
return version;
}
+113
View File
@@ -0,0 +1,113 @@
import * as ElectronStore from "electron-store"
import { Cluster, ClusterBaseInfo } from "../main/cluster";
import { getAppVersion } from "./app-utils"
import * as version200Beta2 from "./migrations/cluster-store/2.0.0-beta.2"
import * as version241 from "./migrations/cluster-store/2.4.1"
import * as version260Beta2 from "./migrations/cluster-store/2.6.0-beta.2"
import * as version260Beta3 from "./migrations/cluster-store/2.6.0-beta.3"
import * as version270Beta0 from "./migrations/cluster-store/2.7.0-beta.0"
import * as version270Beta1 from "./migrations/cluster-store/2.7.0-beta.1"
export class ClusterStore {
private static instance: ClusterStore;
public store: ElectronStore;
private constructor() {
this.store = new ElectronStore({
name: "lens-cluster-store",
projectVersion: getAppVersion(),
accessPropertiesByDotNotation: false, // To make dots safe in cluster context names
migrations: {
"2.0.0-beta.2": version200Beta2.migration,
"2.4.1": version241.migration,
"2.6.0-beta.2": version260Beta2.migration,
"2.6.0-beta.3": version260Beta3.migration,
"2.7.0-beta.0": version270Beta0.migration,
"2.7.0-beta.1": version270Beta1.migration
}
})
}
public getAllClusterObjects(): Array<Cluster> {
return this.store.get("clusters", []).map((clusterInfo: ClusterBaseInfo) => {
return new Cluster(clusterInfo)
})
}
public getAllClusters(): Array<ClusterBaseInfo> {
return this.store.get("clusters", [])
}
public removeCluster(id: string): void {
this.store.delete(id);
const clusterBaseInfos = this.getAllClusters()
const index = clusterBaseInfos.findIndex((cbi) => cbi.id === id)
if (index !== -1) {
clusterBaseInfos.splice(index, 1)
this.store.set("clusters", clusterBaseInfos)
}
}
public removeClustersByWorkspace(workspace: string) {
this.getAllClusters().forEach((cluster) => {
if (cluster.workspace === workspace) {
this.removeCluster(cluster.id)
}
})
}
public getCluster(id: string): Cluster {
const cluster = this.getAllClusterObjects().find((cluster) => cluster.id === id)
if (cluster) { return cluster}
return null
}
public storeCluster(cluster: ClusterBaseInfo) {
const clusters = this.getAllClusters();
const index = clusters.findIndex((cl) => cl.id === cluster.id)
const storable = {
id: cluster.id,
kubeConfig: cluster.kubeConfig,
preferences: cluster.preferences,
workspace: cluster.workspace
}
if (index === -1) {
clusters.push(storable)
} else {
clusters[index] = storable
}
this.store.set("clusters", clusters)
}
public storeClusters(clusters: ClusterBaseInfo[]) {
clusters.forEach((cluster: ClusterBaseInfo) => {
this.removeCluster(cluster.id)
this.storeCluster(cluster)
})
}
public reloadCluster(cluster: ClusterBaseInfo): void {
const storedCluster = this.getCluster(cluster.id);
if (storedCluster) {
cluster.kubeConfig = storedCluster.kubeConfig
cluster.preferences = storedCluster.preferences
cluster.workspace = storedCluster.workspace
}
}
static getInstance(): ClusterStore {
if(!ClusterStore.instance) {
ClusterStore.instance = new ClusterStore();
}
return ClusterStore.instance;
}
static resetInstance() {
ClusterStore.instance = null
}
}
const clusterStore: ClusterStore = ClusterStore.getInstance();
export { clusterStore };
@@ -0,0 +1,13 @@
/* Early store format had the kubeconfig directly under context name, this moves
it under the kubeConfig key */
export function migration(store: any) {
console.log("CLUSTER STORE, MIGRATION: 2.0.0-beta.2");
for (const value of store) {
const contextName = value[0];
// Looping all the keys gives out the store internal stuff too...
if(contextName === "__internal__" || value[1].hasOwnProperty('kubeConfig')) continue;
store.set(contextName, { kubeConfig: value[1] });
}
}
@@ -0,0 +1,11 @@
// Cleans up a store that had the state related data stored
export function migration(store: any) {
console.log("CLUSTER STORE, MIGRATION: 2.4.1");
for (const value of store) {
const contextName = value[0];
if(contextName === "__internal__") continue;
const cluster = value[1];
store.set(contextName, { kubeConfig: cluster.kubeConfig, icon: cluster.icon || null, preferences: cluster.preferences || {} });
}
}
@@ -0,0 +1,15 @@
// Move cluster icon from root to preferences
export function migration(store: any) {
console.log("CLUSTER STORE, MIGRATION: 2.6.0-beta.2");
for (const value of store) {
const clusterKey = value[0];
if(clusterKey === "__internal__") continue
const cluster = value[1];
if(!cluster.preferences) cluster.preferences = {};
if(cluster.icon) {
cluster.preferences.icon = cluster.icon;
delete(cluster["icon"]);
}
store.set(clusterKey, { contextName: clusterKey, kubeConfig: value[1].kubeConfig, preferences: value[1].preferences });
}
}
@@ -0,0 +1,35 @@
import * as yaml from "js-yaml"
// Convert access token and expiry from arrays into strings
export function migration(store: any) {
console.log("CLUSTER STORE, MIGRATION: 2.6.0-beta.3");
for (const value of store) {
const clusterKey = value[0];
if(clusterKey === "__internal__") continue
const cluster = value[1];
if(!cluster.kubeConfig) continue
const kubeConfig = yaml.safeLoad(cluster.kubeConfig)
if(!kubeConfig.hasOwnProperty('users')) continue
const userObj = kubeConfig.users[0]
if (userObj) {
const user = userObj.user
if (user["auth-provider"] && user["auth-provider"].config) {
const authConfig = user["auth-provider"].config
if (authConfig["access-token"]) {
authConfig["access-token"] = `${authConfig["access-token"]}`
}
if (authConfig.expiry) {
authConfig.expiry = `${authConfig.expiry}`
}
console.log(authConfig)
user["auth-provider"].config = authConfig
kubeConfig.users = [{
name: userObj.name,
user: user
}]
cluster.kubeConfig = yaml.safeDump(kubeConfig)
store.set(clusterKey, cluster)
}
}
}
}
@@ -0,0 +1,11 @@
// Add existing clusters to "default" workspace
export function migration(store: any) {
console.log("CLUSTER STORE, MIGRATION: 2.7.0-beta.0");
for (const value of store) {
const clusterKey = value[0];
if(clusterKey === "__internal__") continue
const cluster = value[1];
cluster.workspace = "default"
store.set(clusterKey, cluster)
}
}
@@ -0,0 +1,22 @@
// add id for clusters and store them to array
import { v4 as uuid } from "uuid"
export function migration(store: any) {
console.log("CLUSTER STORE, MIGRATION: 2.7.0-beta.1");
const clusters: any[] = []
for (const value of store) {
const clusterKey = value[0];
if(clusterKey === "__internal__") continue
if(clusterKey === "clusters") continue
const cluster = value[1];
cluster.id = uuid()
if (!cluster.preferences.clusterName) {
cluster.preferences.clusterName = clusterKey
}
clusters.push(cluster)
store.delete(clusterKey)
}
if (clusters.length > 0) {
store.set("clusters", clusters)
}
}
@@ -0,0 +1,4 @@
// Add / reset "lastSeenAppVersion"
export function migration(store: any) {
store.set("lastSeenAppVersion", "0.0.0");
}
+12
View File
@@ -0,0 +1,12 @@
import * as request from "request"
import { userStore } from "../common/user-store"
export function globalRequestOpts(requestOpts: request.Options ) {
const userPrefs = userStore.getPreferences()
if (userPrefs.httpsProxy) {
requestOpts.proxy = userPrefs.httpsProxy
}
requestOpts.rejectUnauthorized = !userPrefs.allowUntrustedCAs;
return requestOpts
}
+6
View File
@@ -0,0 +1,6 @@
import * as winca from "win-ca/api"
import "mac-ca"
if (process.platform === "win32") {
winca.inject("+") // see: https://github.com/ukoloff/win-ca#caveats
}
+42
View File
@@ -0,0 +1,42 @@
import { machineIdSync } from 'node-machine-id'
import { userStore } from "../common/user-store"
import * as ua from "universal-analytics"
const GA_ID = "UA-159377374-1"
export class Tracker {
protected visitor: ua.Visitor
protected machineId: string = null;
protected ip: string = null;
protected appVersion: string;
protected locale: string;
protected electronUA: string;
constructor(app: Electron.App) {
try {
this.visitor = ua(GA_ID, machineIdSync(), {strictCidFormat: false})
} catch (error) {
this.visitor = ua(GA_ID)
}
this.visitor.set("dl", "https://lensapptelemetry.lakendlabs.com")
}
public async event(eventCategory: string, eventAction: string) {
return new Promise(async (resolve, reject) => {
if (!this.telemetryAllowed()) {
resolve()
return
}
this.visitor.event({
ec: eventCategory,
ea: eventAction
}).send()
resolve()
})
}
protected telemetryAllowed() {
const userPrefs = userStore.getPreferences()
return !!userPrefs.allowTelemetry
}
}
+94
View File
@@ -0,0 +1,94 @@
import * as ElectronStore from "electron-store"
import * as appUtil from "./app-utils"
import * as version210Beta4 from "./migrations/user-store/2.1.0-beta.4"
export interface User {
id?: string;
bio?: string;
company?: string;
email?: string;
'first-name'?: string;
'is-customer'?: boolean;
'is-partner'?: boolean;
'last-name'?: string;
'lens-invitation-used'?: boolean;
location?: string;
//notifications?: array;
url?: string;
'user-avatar-src'?: string;
username: string;
verified?: boolean;
}
export interface UserPreferences {
httpsProxy?: string;
colorTheme?: string;
allowUntrustedCAs?: boolean;
allowTelemetry?: boolean;
}
export class UserStore {
private static instance: UserStore;
public store: ElectronStore;
private constructor() {
this.store = new ElectronStore({
projectVersion: appUtil.getAppVersion(),
migrations: {
"2.1.0-beta.4": version210Beta4.migration,
}
});
}
public lastSeenAppVersion() {
return this.store.get('lastSeenAppVersion', "0.0.0")
}
public setLastSeenAppVersion(version: string) {
this.store.set('lastSeenAppVersion', version)
}
public getSeenContexts(): Array<string> {
return this.store.get("seenContexts", [])
}
public storeSeenContext(newContexts: string[]) {
const seenContexts = this.getSeenContexts().concat(newContexts)
// store unique contexts by casting array to set first
const newContextSet = new Set(seenContexts)
const allContexts = [...newContextSet]
this.store.set("seenContexts", allContexts)
return allContexts
}
public setPreferences(preferences: UserPreferences) {
this.store.set('preferences', preferences)
}
public getPreferences(): UserPreferences {
const prefs = this.store.get("preferences", {})
if (!prefs.colorTheme) {
prefs.colorTheme = "dark"
}
if (prefs.allowTelemetry === undefined) {
prefs.allowTelemetry = true
}
return prefs
}
static getInstance(): UserStore {
if(!UserStore.instance) {
UserStore.instance = new UserStore();
}
return UserStore.instance;
}
static resetInstance() {
UserStore.instance = null
}
}
const userStore: UserStore = UserStore.getInstance();
export { userStore };
+74
View File
@@ -0,0 +1,74 @@
import * as ElectronStore from "electron-store"
import { clusterStore } from "./cluster-store"
export interface WorkspaceData {
id: string;
name: string;
description?: string;
}
export class Workspace implements WorkspaceData {
public id: string
public name: string
public description?: string
public constructor(data: WorkspaceData) {
Object.assign(this, data)
}
}
export class WorkspaceStore {
private static instance: WorkspaceStore;
public store: ElectronStore;
private constructor() {
this.store = new ElectronStore({
name: "lens-workspace-store"
})
}
public storeWorkspace(workspace: WorkspaceData) {
const workspaces = this.getAllWorkspaces()
const index = workspaces.findIndex((w) => w.id === workspace.id)
if (index !== -1) {
workspaces[index] = workspace
} else {
workspaces.push(workspace)
}
this.store.set("workspaces", workspaces)
}
public removeWorkspace(workspace: Workspace) {
const workspaces = this.getAllWorkspaces()
const index = workspaces.findIndex((w) => w.id === workspace.id)
if (index !== -1) {
clusterStore.removeClustersByWorkspace(workspace.id)
workspaces.splice(index, 1)
this.store.set("workspaces", workspaces)
}
}
public getAllWorkspaces(): Array<Workspace> {
const workspacesData: WorkspaceData[] = this.store.get("workspaces", [])
return workspacesData.map((wsd) => new Workspace(wsd))
}
static getInstance(): WorkspaceStore {
if (!WorkspaceStore.instance) {
WorkspaceStore.instance = new WorkspaceStore()
}
return WorkspaceStore.instance
}
}
const workspaceStore: WorkspaceStore = WorkspaceStore.getInstance()
if (workspaceStore.getAllWorkspaces().length === 0) {
workspaceStore.storeWorkspace({
id: "default",
name: "default"
})
}
export { workspaceStore }
+104
View File
@@ -0,0 +1,104 @@
import { Feature, FeatureStatus } from "../main/feature"
import {KubeConfig, AppsV1Api, RbacAuthorizationV1Api} from "@kubernetes/client-node"
import * as 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 {
name = 'metrics';
latestVersion = "v2.11.1"
config: MetricsConfiguration = {
persistence: {
enabled: false,
storageClass: null,
size: "20G",
},
nodeExporter: {
enabled: true,
},
retention: {
time: "7d",
size: "5GB",
},
kubeStateMetrics: {
enabled: true,
},
alertManagers: null,
replicas: 1,
storageClass: null,
};
async install(cluster: Cluster): Promise<boolean> {
// Check if there are storageclasses
const storageClient = cluster.contextHandler.kc.makeApiClient(k8s.StorageV1Api)
const scs = await storageClient.listStorageClass();
scs.body.items.forEach(sc => {
if(sc.metadata.annotations &&
(sc.metadata.annotations['storageclass.kubernetes.io/is-default-class'] === 'true' || sc.metadata.annotations['storageclass.beta.kubernetes.io/is-default-class'] === 'true')) {
this.config.persistence.enabled = true;
}
});
return super.install(cluster)
}
async featureStatus(kc: KubeConfig): Promise<FeatureStatus> {
return new Promise<FeatureStatus>( async (resolve, reject) => {
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)
resolve(status)
} catch(error) {
resolve(status)
}
});
}
async uninstall(cluster: Cluster): Promise<boolean> {
return new Promise<boolean>(async (resolve, reject) => {
const rbacClient = cluster.contextHandler.kc.makeApiClient(RbacAuthorizationV1Api)
try {
await this.deleteNamespace(cluster.contextHandler.kc, "lens-metrics")
await rbacClient.deleteClusterRole("lens-prometheus");
await rbacClient.deleteClusterRoleBinding("lens-prometheus");
resolve(true);
} catch(error) {
reject(error);
}
});
}
}
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: lens-metrics
+272
View File
@@ -0,0 +1,272 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-config
namespace: lens-metrics
data:
prometheus.yaml: |-
# Global config
global:
scrape_interval: 15s
{{#if alertManagers}}
# AlertManager
alerting:
alertmanagers:
- static_configs:
- targets:
{{#each alertManagers}}
- {{this}}
{{/each}}
{{/if}}
# Scrape configs for running Prometheus on a Kubernetes cluster.
# This uses separate scrape configs for cluster components (i.e. API server, node)
# and services to allow each to use different authentication configs.
#
# Kubernetes labels will be added as Prometheus labels on metrics via the
# `labelmap` relabeling action.
scrape_configs:
# Scrape config for API servers.
#
# Kubernetes exposes API servers as endpoints to the default/kubernetes
# service so this uses `endpoints` role and uses relabelling to only keep
# the endpoints associated with the default/kubernetes service using the
# default named port `https`. This works for single API server deployments as
# well as HA API server deployments.
- job_name: 'kubernetes-apiservers'
kubernetes_sd_configs:
- role: endpoints
scheme: https
tls_config:
ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
# Using endpoints to discover kube-apiserver targets finds the pod IP
# (host IP since apiserver uses host network) which is not used in
# the server certificate.
insecure_skip_verify: true
bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token
# Keep only the default/kubernetes service endpoints for the https port. This
# will add targets for each API server which Kubernetes adds an endpoint to
# the default/kubernetes service.
relabel_configs:
- source_labels: [__meta_kubernetes_namespace, __meta_kubernetes_service_name, __meta_kubernetes_endpoint_port_name]
action: keep
regex: default;kubernetes;https
- replacement: apiserver
action: replace
target_label: job
# Scrape config for node (i.e. kubelet) /metrics (e.g. 'kubelet_'). Explore
# metrics from a node by scraping kubelet (127.0.0.1:10250/metrics).
- job_name: 'kubelet'
kubernetes_sd_configs:
- role: node
scheme: https
tls_config:
ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
# Kubelet certs don't have any fixed IP SANs
insecure_skip_verify: true
bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token
relabel_configs:
- action: labelmap
regex: __meta_kubernetes_node_label_(.+)
- replacement: 'lens-metrics'
target_label: kubernetes_namespace
metric_relabel_configs:
- source_labels:
- namespace
action: replace
regex: (.+)
target_label: kubernetes_namespace
# Scrape config for Kubelet cAdvisor. Explore metrics from a node by
# scraping kubelet (127.0.0.1:10250/metrics/cadvisor).
- job_name: 'kubernetes-cadvisor'
kubernetes_sd_configs:
- role: node
scheme: https
metrics_path: /metrics/cadvisor
tls_config:
# Kubelet certs don't have any fixed IP SANs
insecure_skip_verify: true
ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token
relabel_configs:
- action: labelmap
regex: __meta_kubernetes_node_label_(.+)
metric_relabel_configs:
- source_labels:
- namespace
action: replace
target_label: kubernetes_namespace
- source_labels:
- pod
regex: (.*)
replacement: $1
action: replace
target_label: pod_name
- source_labels:
- container
regex: (.*)
replacement: $1
action: replace
target_label: container_name
# Scrap etcd metrics from masters via etcd-scraper-proxy
- job_name: 'etcd'
kubernetes_sd_configs:
- role: pod
scheme: http
relabel_configs:
- source_labels: [__meta_kubernetes_namespace]
action: keep
regex: 'kube-system'
- source_labels: [__meta_kubernetes_pod_label_component]
action: keep
regex: 'etcd-scraper-proxy'
- action: labelmap
regex: __meta_kubernetes_pod_label_(.+)
# Scrape config for service endpoints.
#
# The relabeling allows the actual service scrape endpoint to be configured
# via the following annotations:
#
# * `prometheus.io/scrape`: Only scrape services that have a value of `true`
# * `prometheus.io/scheme`: If the metrics endpoint is secured then you will need
# to set this to `https` & most likely set the `tls_config` of the scrape config.
# * `prometheus.io/path`: If the metrics path is not `/metrics` override this.
# * `prometheus.io/port`: If the metrics are exposed on a different port to the
# service then set this appropriately.
- job_name: 'kubernetes-service-endpoints'
kubernetes_sd_configs:
- role: endpoints
relabel_configs:
- source_labels: [__meta_kubernetes_service_annotation_prometheus_io_scrape]
action: keep
regex: true
- source_labels: [__meta_kubernetes_service_annotation_prometheus_io_scheme]
action: replace
target_label: __scheme__
regex: (https?)
- source_labels: [__meta_kubernetes_service_annotation_prometheus_io_path]
action: replace
target_label: __metrics_path__
regex: (.+)
- source_labels: [__address__, __meta_kubernetes_service_annotation_prometheus_io_port]
action: replace
target_label: __address__
regex: ([^:]+)(?::\d+)?;(\d+)
replacement: $1:$2
- action: labelmap
regex: __meta_kubernetes_service_label_(.+)
- source_labels: [__meta_kubernetes_service_name]
action: replace
target_label: job
- action: replace
source_labels:
- __meta_kubernetes_pod_node_name
target_label: kubernetes_node
- source_labels: [__meta_kubernetes_namespace]
action: replace
target_label: kubernetes_namespace
metric_relabel_configs:
- source_labels:
- namespace
action: replace
regex: (.+)
target_label: kubernetes_namespace
# Example scrape config for probing services via the Blackbox Exporter.
#
# The relabeling allows the actual service scrape endpoint to be configured
# via the following annotations:
#
# * `prometheus.io/probe`: Only probe services that have a value of `true`
- job_name: 'kubernetes-services'
metrics_path: /probe
params:
module: [http_2xx]
kubernetes_sd_configs:
- role: service
relabel_configs:
- source_labels: [__meta_kubernetes_service_annotation_prometheus_io_probe]
action: keep
regex: true
- source_labels: [__address__]
target_label: __param_target
- target_label: __address__
replacement: blackbox
- source_labels: [__param_target]
target_label: instance
- action: labelmap
regex: __meta_kubernetes_service_label_(.+)
- source_labels: [__meta_kubernetes_service_name]
target_label: job
metric_relabel_configs:
- source_labels:
- namespace
action: replace
regex: (.+)
target_label: kubernetes_namespace
# Example scrape config for pods
#
# The relabeling allows the actual pod scrape endpoint to be configured via the
# following annotations:
#
# * `prometheus.io/scrape`: Only scrape pods that have a value of `true`
# * `prometheus.io/path`: If the metrics path is not `/metrics` override this.
# * `prometheus.io/port`: Scrape the pod on the indicated port instead of the
# pod's declared ports (default is a port-free target if none are declared).
- job_name: 'kubernetes-pods'
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: true
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
action: replace
target_label: __metrics_path__
regex: (.+)
- source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port]
action: replace
regex: ([^:]+)(?::\d+)?;(\d+)
replacement: $1:$2
target_label: __address__
- action: labelmap
regex: __meta_kubernetes_pod_label_(.+)
- source_labels: [__meta_kubernetes_namespace]
action: replace
target_label: kubernetes_namespace
- source_labels: [__meta_kubernetes_pod_name]
action: replace
target_label: kubernetes_pod_name
metric_relabel_configs:
- source_labels:
- namespace
action: replace
regex: (.+)
target_label: kubernetes_namespace
# Rule files
rule_files:
- "/etc/prometheus/rules/*.rules"
- "/etc/prometheus/rules/*.yaml"
- "/etc/prometheus/rules/*.yml"
+16
View File
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: prometheus
namespace: lens-metrics
annotations:
prometheus.io/scrape: 'true'
spec:
type: ClusterIP
selector:
name: prometheus
ports:
- name: web
protocol: TCP
port: 80
targetPort: 9090
+116
View File
@@ -0,0 +1,116 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: prometheus
namespace: lens-metrics
spec:
replicas: {{replicas}}
serviceName: prometheus
selector:
matchLabels:
name: prometheus
template:
metadata:
labels:
name: prometheus
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/os
operator: In
values:
- linux
- matchExpressions:
- key: beta.kubernetes.io/os
operator: In
values:
- linux
# <%- if config.node_selector -%>
# nodeSelector:
# <%- node_selector.to_h.each do |key, value| -%>
# <%= key %>: <%= value %>
# <%- end -%>
# <%- end -%>
# <%- unless config.tolerations.empty? -%>
# tolerations:
# <%- config.tolerations.each do |t| -%>
# -
# <%- t.each do |k,v| -%>
# <%= k %>: <%= v %>
# <%- end -%>
# <%- end -%>
# <%- end -%>
serviceAccountName: prometheus
initContainers:
- name: chown
image: docker.io/alpine:3.9
command: ["chown", "-R", "65534:65534", "/var/lib/prometheus"]
volumeMounts:
- name: data
mountPath: /var/lib/prometheus
containers:
- name: prometheus
image: docker.io/kontenapharos/prometheus:v2.11.1
args:
- --web.listen-address=0.0.0.0:9090
- --config.file=/etc/prometheus/prometheus.yaml
- --storage.tsdb.path=/var/lib/prometheus
- --storage.tsdb.retention.time={{retention.time}}
- --storage.tsdb.retention.size={{retention.size}}
- --storage.tsdb.min-block-duration=2h
- --storage.tsdb.max-block-duration=2h
ports:
- name: web
containerPort: 9090
volumeMounts:
- name: config
mountPath: /etc/prometheus
- name: rules
mountPath: /etc/prometheus/rules
- name: data
mountPath: /var/lib/prometheus
readinessProbe:
httpGet:
path: /-/ready
port: 9090
initialDelaySeconds: 10
timeoutSeconds: 10
livenessProbe:
httpGet:
path: /-/healthy
port: 9090
initialDelaySeconds: 10
timeoutSeconds: 10
resources:
requests:
cpu: 100m
memory: 512Mi
terminationGracePeriodSeconds: 30
volumes:
- name: config
configMap:
name: prometheus-config
- name: rules
configMap:
name: prometheus-rules
{{#unless persistence.enabled}}
- name: data
emptyDir: {}
{{/unless}}
{{#if persistence.enabled}}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
{{#if persistence.storageClass}}
storageClassName: "{{persistence.storageClass}}"
{{/if}}
resources:
requests:
storage: {{persistence.size}}
{{/if}}
+514
View File
@@ -0,0 +1,514 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-rules
namespace: lens-metrics
data:
alertmanager.rules.yaml: |
groups:
- name: alertmanager.rules
rules:
- alert: AlertmanagerConfigInconsistent
expr: count_values("config_hash", alertmanager_config_hash) BY (service) / ON(service)
GROUP_LEFT() label_replace(prometheus_operator_alertmanager_spec_replicas, "service",
"alertmanager-$1", "alertmanager", "(.*)") != 1
for: 5m
labels:
severity: critical
annotations:
description: The configuration of the instances of the Alertmanager cluster
`{{$labels.service}}` are out of sync.
- alert: AlertmanagerDownOrMissing
expr: label_replace(prometheus_operator_alertmanager_spec_replicas, "job", "alertmanager-$1",
"alertmanager", "(.*)") / ON(job) GROUP_RIGHT() sum(up) BY (job) != 1
for: 5m
labels:
severity: warning
annotations:
description: An unexpected number of Alertmanagers are scraped or Alertmanagers
disappeared from discovery.
- alert: AlertmanagerFailedReload
expr: alertmanager_config_last_reload_successful == 0
for: 10m
labels:
severity: warning
annotations:
description: Reloading Alertmanager's configuration has failed for {{ $labels.namespace
}}/{{ $labels.pod}}.
etcd3.rules.yaml: |
groups:
- name: ./etcd3.rules
rules:
- alert: InsufficientMembers
expr: count(up{job="etcd"} == 0) > (count(up{job="etcd"}) / 2 - 1)
for: 3m
labels:
severity: critical
annotations:
description: If one more etcd member goes down the cluster will be unavailable
summary: etcd cluster insufficient members
- alert: NoLeader
expr: etcd_server_has_leader{job="etcd"} == 0
for: 1m
labels:
severity: critical
annotations:
description: etcd member {{ $labels.instance }} has no leader
summary: etcd member has no leader
- alert: HighNumberOfLeaderChanges
expr: increase(etcd_server_leader_changes_seen_total{job="etcd"}[1h]) > 3
labels:
severity: warning
annotations:
description: etcd instance {{ $labels.instance }} has seen {{ $value }} leader
changes within the last hour
summary: a high number of leader changes within the etcd cluster are happening
- alert: GRPCRequestsSlow
expr: histogram_quantile(0.99, sum(rate(grpc_server_handling_seconds_bucket{job="etcd",grpc_type="unary"}[5m])) by (grpc_service, grpc_method, le))
> 0.15
for: 10m
labels:
severity: critical
annotations:
description: on etcd instance {{ $labels.instance }} gRPC requests to {{ $labels.grpc_method
}} are slow
summary: slow gRPC requests
- alert: HighNumberOfFailedHTTPRequests
expr: sum(rate(etcd_http_failed_total{job="etcd"}[5m])) BY (method) / sum(rate(etcd_http_received_total{job="etcd"}[5m]))
BY (method) > 0.01
for: 10m
labels:
severity: warning
annotations:
description: '{{ $value }}% of requests for {{ $labels.method }} failed on etcd
instance {{ $labels.instance }}'
summary: a high number of HTTP requests are failing
- alert: HighNumberOfFailedHTTPRequests
expr: sum(rate(etcd_http_failed_total{job="etcd"}[5m])) BY (method) / sum(rate(etcd_http_received_total{job="etcd"}[5m]))
BY (method) > 0.05
for: 5m
labels:
severity: critical
annotations:
description: '{{ $value }}% of requests for {{ $labels.method }} failed on etcd
instance {{ $labels.instance }}'
summary: a high number of HTTP requests are failing
- alert: HTTPRequestsSlow
expr: histogram_quantile(0.99, rate(etcd_http_successful_duration_seconds_bucket[5m]))
> 0.15
for: 10m
labels:
severity: warning
annotations:
description: on etcd instance {{ $labels.instance }} HTTP requests to {{ $labels.method
}} are slow
summary: slow HTTP requests
- alert: EtcdMemberCommunicationSlow
expr: histogram_quantile(0.99, rate(etcd_network_peer_round_trip_time_seconds_bucket[5m]))
> 0.15
for: 10m
labels:
severity: warning
annotations:
description: etcd instance {{ $labels.instance }} member communication with
{{ $labels.To }} is slow
summary: etcd member communication is slow
- alert: HighNumberOfFailedProposals
expr: increase(etcd_server_proposals_failed_total{job="etcd"}[1h]) > 5
labels:
severity: warning
annotations:
description: etcd instance {{ $labels.instance }} has seen {{ $value }} proposal
failures within the last hour
summary: a high number of proposals within the etcd cluster are failing
- alert: HighFsyncDurations
expr: histogram_quantile(0.99, rate(etcd_disk_wal_fsync_duration_seconds_bucket[5m]))
> 0.5
for: 10m
labels:
severity: warning
annotations:
description: etcd instance {{ $labels.instance }} fync durations are high
summary: high fsync durations
- alert: HighCommitDurations
expr: histogram_quantile(0.99, rate(etcd_disk_backend_commit_duration_seconds_bucket[5m]))
> 0.25
for: 10m
labels:
severity: warning
annotations:
description: etcd instance {{ $labels.instance }} commit durations are high
summary: high commit durations
general.rules.yaml: |
groups:
- name: general.rules
rules:
- alert: TargetDown
expr: 100 * (count(up == 0) BY (job) / count(up) BY (job)) > 10
for: 10m
labels:
severity: warning
annotations:
description: '{{ $value }}% of {{ $labels.job }} targets are down.'
summary: Targets are down
- record: fd_utilization
expr: process_open_fds / process_max_fds
- alert: FdExhaustionClose
expr: predict_linear(fd_utilization[1h], 3600 * 4) > 1
for: 10m
labels:
severity: warning
annotations:
description: '{{ $labels.job }}: {{ $labels.namespace }}/{{ $labels.pod }} instance
will exhaust in file/socket descriptors within the next 4 hours'
summary: file descriptors soon exhausted
- alert: FdExhaustionClose
expr: predict_linear(fd_utilization[10m], 3600) > 1
for: 10m
labels:
severity: critical
annotations:
description: '{{ $labels.job }}: {{ $labels.namespace }}/{{ $labels.pod }} instance
will exhaust in file/socket descriptors within the next hour'
summary: file descriptors soon exhausted
kube-state-metrics.rules.yaml: |
groups:
- name: kube-state-metrics.rules
rules:
- alert: DeploymentGenerationMismatch
expr: kube_deployment_status_observed_generation != kube_deployment_metadata_generation
for: 15m
labels:
severity: warning
annotations:
description: Observed deployment generation does not match expected one for
deployment {{$labels.namespaces}}/{{$labels.deployment}}
summary: Deployment is outdated
- alert: DeploymentReplicasNotUpdated
expr: ((kube_deployment_status_replicas_updated != kube_deployment_spec_replicas)
or (kube_deployment_status_replicas_available != kube_deployment_spec_replicas))
unless (kube_deployment_spec_paused == 1)
for: 15m
labels:
severity: warning
annotations:
description: Replicas are not updated and available for deployment {{$labels.namespaces}}/{{$labels.deployment}}
summary: Deployment replicas are outdated
- alert: DaemonSetRolloutStuck
expr: kube_daemonset_status_number_ready / kube_daemonset_status_desired_number_scheduled
* 100 < 100
for: 15m
labels:
severity: warning
annotations:
description: Only {{$value}}% of desired pods scheduled and ready for daemon
set {{$labels.namespaces}}/{{$labels.daemonset}}
summary: DaemonSet is missing pods
- alert: K8SDaemonSetsNotScheduled
expr: kube_daemonset_status_desired_number_scheduled - kube_daemonset_status_current_number_scheduled
> 0
for: 10m
labels:
severity: warning
annotations:
description: A number of daemonsets are not scheduled.
summary: Daemonsets are not scheduled correctly
- alert: DaemonSetsMissScheduled
expr: kube_daemonset_status_number_misscheduled > 0
for: 10m
labels:
severity: warning
annotations:
description: A number of daemonsets are running where they are not supposed
to run.
summary: Daemonsets are not scheduled correctly
- alert: PodFrequentlyRestarting
expr: increase(kube_pod_container_status_restarts_total[1h]) > 5
for: 10m
labels:
severity: warning
annotations:
description: Pod {{$labels.namespaces}}/{{$labels.pod}} restarted {{$value}}
times within the last hour
summary: Pod is restarting frequently
kubelet.rules.yaml: |
groups:
- name: kubelet.rules
rules:
- alert: K8SNodeNotReady
expr: kube_node_status_condition{condition="Ready",status="true"} == 0
for: 1h
labels:
severity: warning
annotations:
description: The Kubelet on {{ $labels.node }} has not checked in with the API,
or has set itself to NotReady, for more than an hour
summary: Node status is NotReady
- alert: K8SManyNodesNotReady
expr: count(kube_node_status_condition{condition="Ready",status="true"} == 0)
> 1 and (count(kube_node_status_condition{condition="Ready",status="true"} ==
0) / count(kube_node_status_condition{condition="Ready",status="true"})) > 0.2
for: 1m
labels:
severity: critical
annotations:
description: '{{ $value }}% of Kubernetes nodes are not ready'
- alert: K8SKubeletDown
expr: count(up{job="kubelet"} == 0) / count(up{job="kubelet"}) * 100 > 3
for: 1h
labels:
severity: warning
annotations:
description: Prometheus failed to scrape {{ $value }}% of kubelets.
- alert: K8SKubeletDown
expr: (absent(up{job="kubelet"} == 1) or count(up{job="kubelet"} == 0) / count(up{job="kubelet"}))
* 100 > 10
for: 1h
labels:
severity: critical
annotations:
description: Prometheus failed to scrape {{ $value }}% of kubelets, or all Kubelets
have disappeared from service discovery.
summary: Many Kubelets cannot be scraped
- alert: K8SKubeletTooManyPods
expr: kubelet_running_pod_count > 100
for: 10m
labels:
severity: warning
annotations:
description: Kubelet {{$labels.instance}} is running {{$value}} pods, close
to the limit of 110
summary: Kubelet is close to pod limit
kubernetes.rules.yaml: |
groups:
- name: kubernetes.rules
rules:
- record: pod_name:container_memory_usage_bytes:sum
expr: sum(container_memory_usage_bytes{container_name!="POD",pod_name!=""}) BY
(pod_name)
- record: pod_name:container_spec_cpu_shares:sum
expr: sum(container_spec_cpu_shares{container_name!="POD",pod_name!=""}) BY (pod_name)
- record: pod_name:container_cpu_usage:sum
expr: sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name!=""}[5m]))
BY (pod_name)
- record: pod_name:container_fs_usage_bytes:sum
expr: sum(container_fs_usage_bytes{container_name!="POD",pod_name!=""}) BY (pod_name)
- record: namespace:container_memory_usage_bytes:sum
expr: sum(container_memory_usage_bytes{container_name!=""}) BY (namespace)
- record: namespace:container_spec_cpu_shares:sum
expr: sum(container_spec_cpu_shares{container_name!=""}) BY (namespace)
- record: namespace:container_cpu_usage:sum
expr: sum(rate(container_cpu_usage_seconds_total{container_name!="POD"}[5m]))
BY (namespace)
- record: cluster:memory_usage:ratio
expr: sum(container_memory_usage_bytes{container_name!="POD",pod_name!=""}) BY
(cluster) / sum(machine_memory_bytes) BY (cluster)
- record: cluster:container_spec_cpu_shares:ratio
expr: sum(container_spec_cpu_shares{container_name!="POD",pod_name!=""}) / 1000
/ sum(machine_cpu_cores)
- record: cluster:container_cpu_usage:ratio
expr: sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name!=""}[5m]))
/ sum(machine_cpu_cores)
- record: apiserver_latency_seconds:quantile
expr: histogram_quantile(0.99, rate(apiserver_request_latencies_bucket[5m])) /
1e+06
labels:
quantile: "0.99"
- record: apiserver_latency:quantile_seconds
expr: histogram_quantile(0.9, rate(apiserver_request_latencies_bucket[5m])) /
1e+06
labels:
quantile: "0.9"
- record: apiserver_latency_seconds:quantile
expr: histogram_quantile(0.5, rate(apiserver_request_latencies_bucket[5m])) /
1e+06
labels:
quantile: "0.5"
- alert: APIServerLatencyHigh
expr: apiserver_latency_seconds:quantile{quantile="0.99",subresource!="log",verb!~"^(?:WATCH|WATCHLIST|PROXY|CONNECT)$"}
> 1
for: 10m
labels:
severity: warning
annotations:
description: the API server has a 99th percentile latency of {{ $value }} seconds
for {{$labels.verb}} {{$labels.resource}}
- alert: APIServerLatencyHigh
expr: apiserver_latency_seconds:quantile{quantile="0.99",subresource!="log",verb!~"^(?:WATCH|WATCHLIST|PROXY|CONNECT)$"}
> 4
for: 10m
labels:
severity: critical
annotations:
description: the API server has a 99th percentile latency of {{ $value }} seconds
for {{$labels.verb}} {{$labels.resource}}
- alert: APIServerErrorsHigh
expr: rate(apiserver_request_count{code=~"^(?:5..)$"}[5m]) / rate(apiserver_request_count[5m])
* 100 > 2
for: 10m
labels:
severity: warning
annotations:
description: API server returns errors for {{ $value }}% of requests
- alert: APIServerErrorsHigh
expr: rate(apiserver_request_count{code=~"^(?:5..)$"}[5m]) / rate(apiserver_request_count[5m])
* 100 > 5
for: 10m
labels:
severity: critical
annotations:
description: API server returns errors for {{ $value }}% of requests
- alert: K8SApiserverDown
expr: absent(up{job="apiserver"} == 1)
for: 20m
labels:
severity: critical
annotations:
description: No API servers are reachable or all have disappeared from service
discovery
- alert: K8sCertificateExpirationNotice
labels:
severity: warning
annotations:
description: Kubernetes API Certificate is expiring soon (less than 7 days)
expr: sum(apiserver_client_certificate_expiration_seconds_bucket{le="604800"}) > 0
- alert: K8sCertificateExpirationNotice
labels:
severity: critical
annotations:
description: Kubernetes API Certificate is expiring in less than 1 day
expr: sum(apiserver_client_certificate_expiration_seconds_bucket{le="86400"}) > 0
node.rules.yaml: |
groups:
- name: node.rules
rules:
- record: instance:node_cpu:rate:sum
expr: sum(rate(node_cpu{mode!="idle",mode!="iowait",mode!~"^(?:guest.*)$"}[3m]))
BY (instance)
- record: instance:node_filesystem_usage:sum
expr: sum((node_filesystem_size{mountpoint="/"} - node_filesystem_free{mountpoint="/"}))
BY (instance)
- record: instance:node_network_receive_bytes:rate:sum
expr: sum(rate(node_network_receive_bytes[3m])) BY (instance)
- record: instance:node_network_transmit_bytes:rate:sum
expr: sum(rate(node_network_transmit_bytes[3m])) BY (instance)
- record: instance:node_cpu:ratio
expr: sum(rate(node_cpu{mode!="idle"}[5m])) WITHOUT (cpu, mode) / ON(instance)
GROUP_LEFT() count(sum(node_cpu) BY (instance, cpu)) BY (instance)
- record: cluster:node_cpu:sum_rate5m
expr: sum(rate(node_cpu{mode!="idle"}[5m]))
- record: cluster:node_cpu:ratio
expr: cluster:node_cpu:rate5m / count(sum(node_cpu) BY (instance, cpu))
- alert: NodeExporterDown
expr: absent(up{job="node-exporter"} == 1)
for: 10m
labels:
severity: warning
annotations:
description: Prometheus could not scrape a node-exporter for more than 10m,
or node-exporters have disappeared from discovery
- alert: NodeDiskRunningFull
expr: predict_linear(node_filesystem_free[6h], 3600 * 24) < 0
for: 30m
labels:
severity: warning
annotations:
description: device {{$labels.device}} on node {{$labels.instance}} is running
full within the next 24 hours (mounted at {{$labels.mountpoint}})
- alert: NodeDiskRunningFull
expr: predict_linear(node_filesystem_free[30m], 3600 * 2) < 0
for: 10m
labels:
severity: critical
annotations:
description: device {{$labels.device}} on node {{$labels.instance}} is running
full within the next 2 hours (mounted at {{$labels.mountpoint}})
- alert: InactiveRAIDDisk
expr: node_md_disks - node_md_disks_active > 0
for: 10m
labels:
severity: warning
annotations:
description: '{{$value}} RAID disk(s) on node {{$labels.instance}} are inactive'
prometheus.rules.yaml: |
groups:
- name: prometheus.rules
rules:
- alert: PrometheusConfigReloadFailed
expr: prometheus_config_last_reload_successful == 0
for: 10m
labels:
severity: warning
annotations:
description: Reloading Prometheus' configuration has failed for {{$labels.namespace}}/{{$labels.pod}}
- alert: PrometheusNotificationQueueRunningFull
expr: predict_linear(prometheus_notifications_queue_length[5m], 60 * 30) > prometheus_notifications_queue_capacity
for: 10m
labels:
severity: warning
annotations:
description: Prometheus' alert notification queue is running full for {{$labels.namespace}}/{{
$labels.pod}}
- alert: PrometheusErrorSendingAlerts
expr: rate(prometheus_notifications_errors_total[5m]) / rate(prometheus_notifications_sent_total[5m])
> 0.01
for: 10m
labels:
severity: warning
annotations:
description: Errors while sending alerts from Prometheus {{$labels.namespace}}/{{
$labels.pod}} to Alertmanager {{$labels.Alertmanager}}
- alert: PrometheusErrorSendingAlerts
expr: rate(prometheus_notifications_errors_total[5m]) / rate(prometheus_notifications_sent_total[5m])
> 0.03
for: 10m
labels:
severity: critical
annotations:
description: Errors while sending alerts from Prometheus {{$labels.namespace}}/{{
$labels.pod}} to Alertmanager {{$labels.Alertmanager}}
- alert: PrometheusNotConnectedToAlertmanagers
expr: prometheus_notifications_alertmanagers_discovered < 1
for: 10m
labels:
severity: warning
annotations:
description: Prometheus {{ $labels.namespace }}/{{ $labels.pod}} is not connected
to any Alertmanagers
- alert: PrometheusTSDBReloadsFailing
expr: increase(prometheus_tsdb_reloads_failures_total[2h]) > 0
for: 12h
labels:
severity: warning
annotations:
description: '{{$labels.job}} at {{$labels.instance}} had {{$value | humanize}}
reload failures over the last four hours.'
summary: Prometheus has issues reloading data blocks from disk
- alert: PrometheusTSDBCompactionsFailing
expr: increase(prometheus_tsdb_compactions_failed_total[2h]) > 0
for: 12h
labels:
severity: warning
annotations:
description: '{{$labels.job}} at {{$labels.instance}} had {{$value | humanize}}
compaction failures over the last four hours.'
summary: Prometheus has issues compacting sample blocks
- alert: PrometheusTSDBWALCorruptions
expr: tsdb_wal_corruptions_total > 0
for: 4h
labels:
severity: warning
annotations:
description: '{{$labels.job}} at {{$labels.instance}} has a corrupted write-ahead
log (WAL).'
summary: Prometheus write-ahead log is corrupted
- alert: PrometheusNotIngestingSamples
expr: rate(prometheus_tsdb_head_samples_appended_total[5m]) <= 0
for: 10m
labels:
severity: warning
annotations:
description: "Prometheus {{ $labels.namespace }}/{{ $labels.pod}} isn't ingesting samples."
summary: "Prometheus isn't ingesting samples"
+15
View File
@@ -0,0 +1,15 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: lens-prometheus
rules:
- apiGroups: [""]
resources:
- nodes
- nodes/metrics
- services
- endpoints
- pods
verbs: ["get", "list", "watch"]
- nonResourceURLs: ["/metrics"]
verbs: ["get"]
@@ -0,0 +1,5 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: prometheus
namespace: lens-metrics
@@ -0,0 +1,12 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: lens-prometheus
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: lens-prometheus
subjects:
- kind: ServiceAccount
name: prometheus
namespace: lens-metrics
@@ -0,0 +1,84 @@
{{#if nodeExporter.enabled}}
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: node-exporter
namespace: lens-metrics
spec:
updateStrategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
selector:
matchLabels:
name: node-exporter
phase: prod
template:
metadata:
labels:
name: node-exporter
phase: prod
annotations:
seccomp.security.alpha.kubernetes.io/pod: 'docker/default'
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/os
operator: In
values:
- linux
- matchExpressions:
- key: beta.kubernetes.io/os
operator: In
values:
- linux
securityContext:
runAsNonRoot: true
runAsUser: 65534
hostPID: true
containers:
- name: node-exporter
image: docker.io/kontenapharos/prometheus-node-exporter:v0.18.0
args:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/host/root
- --collector.filesystem.ignored-mount-points=^/(dev|proc|sys|var/lib/docker|var/lib/containers/.+)($|/)
- --collector.filesystem.ignored-fs-types=^(autofs|binfmt_misc|cgroup|configfs|debugfs|devpts|devtmpfs|fusectl|hugetlbfs|mqueue|overlay|proc|procfs|pstore|rpc_pipefs|securityfs|sysfs|tracefs)$
ports:
- name: metrics
containerPort: 9100
resources:
requests:
cpu: 10m
memory: 50Mi
limits:
cpu: 200m
memory: 100Mi
volumeMounts:
- name: proc
mountPath: /host/proc
readOnly: true
- name: sys
mountPath: /host/sys
readOnly: true
- name: root
mountPath: /host/root
readOnly: true
tolerations:
- effect: NoSchedule
operator: Exists
volumes:
- name: proc
hostPath:
path: /proc
- name: sys
hostPath:
path: /sys
- name: root
hostPath:
path: /
{{/if}}
@@ -0,0 +1,18 @@
apiVersion: v1
kind: Service
metadata:
name: node-exporter
namespace: lens-metrics
annotations:
prometheus.io/scrape: 'true'
spec:
type: ClusterIP
clusterIP: None
selector:
name: node-exporter
phase: prod
ports:
- name: metrics
protocol: TCP
port: 80
targetPort: 9100
@@ -0,0 +1,51 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: lens-kube-state-metrics
rules:
- apiGroups: [""]
resources:
- configmaps
- secrets
- nodes
- pods
- services
- resourcequotas
- replicationcontrollers
- limitranges
- persistentvolumeclaims
- persistentvolumes
- namespaces
- endpoints
verbs: ["list", "watch"]
- apiGroups: ["extensions"]
resources:
- daemonsets
- deployments
- replicasets
- ingresses
verbs: ["list", "watch"]
- apiGroups: ["apps"]
resources:
- daemonsets
- deployments
- replicasets
- statefulsets
verbs: ["list", "watch"]
- apiGroups: ["batch"]
resources:
- cronjobs
- jobs
verbs: ["list", "watch"]
- apiGroups: ["autoscaling"]
resources:
- horizontalpodautoscalers
verbs: ["list", "watch"]
- apiGroups: ["policy"]
resources:
- poddisruptionbudgets
verbs: ["list", "watch"]
- apiGroups: ["certificates.k8s.io"]
resources:
- certificatesigningrequests
verbs: ["list", "watch"]
@@ -0,0 +1,5 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: kube-state-metrics
namespace: lens-metrics
@@ -0,0 +1,12 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: lens-kube-state-metrics
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: lens-kube-state-metrics
subjects:
- kind: ServiceAccount
name: kube-state-metrics
namespace: lens-metrics
@@ -0,0 +1,51 @@
{{#if kubeStateMetrics.enabled}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: kube-state-metrics
namespace: lens-metrics
spec:
selector:
matchLabels:
name: kube-state-metrics
replicas: 1
template:
metadata:
labels:
name: kube-state-metrics
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/os
operator: In
values:
- linux
- matchExpressions:
- key: beta.kubernetes.io/os
operator: In
values:
- linux
serviceAccountName: kube-state-metrics
containers:
- name: kube-state-metrics
image: docker.io/kontenapharos/prometheus-kube-state-metrics:v1.6.0
ports:
- name: metrics
containerPort: 8080
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
timeoutSeconds: 5
resources:
requests:
cpu: 10m
memory: 150Mi
limits:
cpu: 200m
memory: 150Mi
{{/if}}
@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: kube-state-metrics
namespace: lens-metrics
labels:
name: kube-state-metrics
annotations:
prometheus.io/scrape: 'true'
spec:
ports:
- name: metrics
port: 8080
targetPort: 8080
protocol: TCP
selector:
name: kube-state-metrics
+46
View File
@@ -0,0 +1,46 @@
import { Feature, FeatureStatus } from "../main/feature"
import {KubeConfig, RbacAuthorizationV1Api} from "@kubernetes/client-node"
import { Cluster } from "../main/cluster"
export class UserModeFeature extends Feature {
name = 'user-mode';
latestVersion = "v2.0.0"
async install(cluster: Cluster): Promise<boolean> {
return super.install(cluster)
}
async featureStatus(kc: KubeConfig): Promise<FeatureStatus> {
return new Promise<FeatureStatus>( async (resolve, reject) => {
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
resolve(status)
} catch(error) {
resolve(status)
}
});
}
async uninstall(cluster: Cluster): Promise<boolean> {
return new Promise<boolean>(async (resolve, reject) => {
const rbacClient = cluster.contextHandler.kc.makeApiClient(RbacAuthorizationV1Api)
try {
await rbacClient.deleteClusterRole("lens-user");
await rbacClient.deleteClusterRoleBinding("lens-user");
resolve(true);
} catch(error) {
reject(error);
}
});
}
}
+11
View File
@@ -0,0 +1,11 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: lens-user
rules:
- verbs:
- list
apiGroups:
- ''
resources:
- namespaces
@@ -0,0 +1,12 @@
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
+19
View File
@@ -0,0 +1,19 @@
import { autoUpdater } from "electron-updater"
import logger from "./logger"
export default class AppUpdater {
protected updateInterval: number = (1000 * 60 * 60 * 24) // once a day
constructor() {
autoUpdater.logger = logger
}
public start() {
setInterval(() => {
autoUpdater.checkForUpdatesAndNotify()
}, this.updateInterval)
return autoUpdater.checkForUpdatesAndNotify()
}
}
+278
View File
@@ -0,0 +1,278 @@
import { KubeConfig } from "@kubernetes/client-node"
import { PromiseIpc } from "electron-promise-ipc"
import * as http from "http"
import { Cluster, ClusterBaseInfo } from "./cluster"
import { clusterStore } from "../common/cluster-store"
import * as k8s from "./k8s"
import logger from "./logger"
import { LensProxy } from "./proxy"
import { app } from "electron"
import * as path from "path"
import { promises } from "fs"
import { ensureDir } from "fs-extra"
import * as filenamify from "filenamify"
import { v4 as uuid } from "uuid"
declare const __static: string;
export type FeatureInstallRequest = {
name: string;
clusterId: string;
config: any;
}
export type FeatureInstallResponse = {
success: boolean;
message: string;
}
export type ClusterIconUpload = {
path: string;
name: string;
clusterId: string;
}
export class ClusterManager {
public static readonly clusterIconDir = path.join(app.getPath("userData"), "icons")
protected promiseIpc: any
protected proxyServer: LensProxy
protected port: number
protected clusters: Map<string, Cluster>;
constructor(clusters: Cluster[], port: number) {
this.promiseIpc = new PromiseIpc({ timeout: 2000 })
this.port = port
this.clusters = new Map()
clusters.forEach((clusterInfo) => {
try {
const kc = this.loadKubeConfig(clusterInfo.kubeConfig)
logger.debug(`Starting to load target definitions for ${ kc.currentContext }`)
const cluster = new Cluster({
id: clusterInfo.id,
port: this.port,
kubeConfig: clusterInfo.kubeConfig,
preferences: clusterInfo.preferences,
workspace: clusterInfo.workspace
})
cluster.init(kc)
logger.debug(`Created cluster[id: ${ cluster.id }] for context ${ cluster.contextName }`)
this.clusters.set(cluster.id, cluster)
} catch(error) {
logger.error(`Error while initializing ${clusterInfo.contextName}`)
}
});
logger.debug("clusters after constructor:" + this.clusters.size)
this.listenEvents()
}
public getClusters() {
return this.clusters.values()
}
public getCluster(id: string) {
return this.clusters.get(id)
}
public stop() {
const clusters = Array.from(this.getClusters())
clusters.map(cluster => cluster.stopServer())
}
protected loadKubeConfig(config: string): KubeConfig {
const kc = new KubeConfig();
kc.loadFromString(config);
return kc;
}
protected async addNewCluster(clusterData: ClusterBaseInfo): Promise<Cluster> {
return new Promise(async (resolve, reject) => {
try {
const configs: KubeConfig[] = k8s.loadAndSplitConfig(clusterData.kubeConfig)
if(configs.length == 0) {
reject("No cluster contexts defined")
}
configs.forEach(c => {
k8s.valideConfig(c)
const cluster = new Cluster({
id: uuid(),
port: this.port,
kubeConfig: k8s.dumpConfigYaml(c),
preferences: clusterData.preferences,
workspace: clusterData.workspace
})
cluster.init(c)
cluster.save()
this.clusters.set(cluster.id, cluster)
resolve(cluster)
});
} catch(error) {
logger.error(error)
reject(error)
}
});
}
protected listenEvents() {
this.promiseIpc.on("addCluster", async (clusterData: ClusterBaseInfo) => {
logger.debug(`IPC: addCluster`)
const cluster = await this.addNewCluster(clusterData)
return {
addedCluster: this.clusterResponse(cluster),
allClusters: Array.from(this.getClusters()).map((cluster: Cluster) => this.clusterResponse(cluster))
}
});
this.promiseIpc.on("getClusters", async (workspaceId: string) => {
logger.debug(`IPC: getClusters, workspace ${workspaceId}`)
const workspaceClusters = Array.from(this.getClusters()).filter((cluster) => cluster.workspace === workspaceId)
return workspaceClusters.map((cluster: Cluster) => this.clusterResponse(cluster))
});
this.promiseIpc.on("getCluster", async (id: string) => {
logger.debug(`IPC: getCluster`)
const cluster = this.getCluster(id)
if (cluster) {
await cluster.refreshCluster()
return this.clusterResponse(cluster)
} else {
return null
}
});
this.promiseIpc.on("installFeature", async (installReq: FeatureInstallRequest) => {
logger.debug(`IPC: installFeature for ${installReq.name}`)
const cluster = this.clusters.get(installReq.clusterId)
try {
await cluster.installFeature(installReq.name, installReq.config)
return {success: true, message: ""}
} catch(error) {
return {success: false, message: error}
}
});
this.promiseIpc.on("uninstallFeature", async (installReq: FeatureInstallRequest) => {
logger.debug(`IPC: uninstallFeature for ${installReq.name}`)
const cluster = this.clusters.get(installReq.clusterId)
await cluster.uninstallFeature(installReq.name)
return {success: true, message: ""}
});
this.promiseIpc.on("saveClusterIcon", async (fileUpload: ClusterIconUpload) => {
logger.debug(`IPC: saveClusterIcon for ${fileUpload.clusterId}`)
const cluster = this.getCluster(fileUpload.clusterId)
if (!cluster) {
return {success: false, message: "Cluster not found"}
}
try {
const clusterIcon = await this.uploadClusterIcon(cluster, fileUpload.name, fileUpload.path)
clusterStore.reloadCluster(cluster);
if(!cluster.preferences) cluster.preferences = {};
cluster.preferences.icon = clusterIcon
clusterStore.storeCluster(cluster);
return {success: true, cluster: this.clusterResponse(cluster), message: ""}
} catch(error) {
return {success: false, message: error}
}
});
this.promiseIpc.on("resetClusterIcon", async (id: string) => {
logger.debug(`IPC: resetClusterIcon`)
const cluster = this.getCluster(id)
if (cluster && cluster.preferences) {
cluster.preferences.icon = null;
clusterStore.storeCluster(cluster)
return {success: true, cluster: this.clusterResponse(cluster), message: ""}
} else {
return {success: false, message: "Cluster not found"}
}
});
this.promiseIpc.on("refreshCluster", async (clusterId: string) => {
const cluster = this.clusters.get(clusterId)
await cluster.refreshCluster()
return this.clusterResponse(cluster)
});
this.promiseIpc.on("stopCluster", (clusterId: string) => {
logger.debug(`IPC: stopCluster: ${clusterId}`)
const cluster = this.clusters.get(clusterId)
if (cluster) {
cluster.stopServer()
return true
}
return false
});
this.promiseIpc.on("removeCluster", (ctx: string) => {
logger.debug(`IPC: removeCluster: ${ctx}`)
return this.removeCluster(ctx).map((cluster: Cluster) => this.clusterResponse(cluster))
});
this.promiseIpc.on("clusterStored", (clusterId: string) => {
logger.debug(`IPC: clusterStored: ${clusterId}`)
const cluster = this.clusters.get(clusterId)
if (cluster) {
clusterStore.reloadCluster(cluster);
cluster.stopServer()
}
});
this.promiseIpc.on("preferencesSaved", () => {
logger.debug(`IPC: preferencesSaved`)
this.clusters.forEach((cluster) => {
cluster.stopServer()
})
});
this.promiseIpc.on("getClusterEvents", async (clusterId: string) => {
const cluster = this.clusters.get(clusterId)
return cluster.getEventCount();
});
}
public removeCluster(id: string): Cluster[] {
const cluster = this.clusters.get(id)
if (cluster) {
cluster.stopServer()
clusterStore.removeCluster(cluster.id);
this.clusters.delete(cluster.id)
}
return Array.from(this.clusters.values())
}
public getClusterForRequest(req: http.IncomingMessage): Cluster {
let cluster: Cluster = null
// lens-server is connecting to 127.0.0.1:<port>/<uid>
if (req.headers.host.startsWith("127.0.0.1")) {
const clusterId = req.url.split("/")[1]
if (clusterId) {
cluster = this.clusters.get(clusterId)
if (cluster) {
// we need to swap path prefix so that request is proxied to kube api
req.url = req.url.replace(`/${clusterId}`, "/api-kube")
}
}
} else {
const id = req.headers.host.split(".")[0]
cluster = this.clusters.get(id)
}
return cluster;
}
// TODO: remove this
protected clusterResponse(cluster: Cluster) {
return cluster.toClusterInfo()
}
protected async uploadClusterIcon(cluster: Cluster, fileName: string, src: string): Promise<string> {
await ensureDir(ClusterManager.clusterIconDir)
fileName = filenamify(cluster.contextName + "-" + fileName)
const dest = path.join(ClusterManager.clusterIconDir, fileName)
await promises.copyFile(src, dest)
return "store:///icons/" + fileName
}
}
+311
View File
@@ -0,0 +1,311 @@
import { ContextHandler } from "./context-handler"
import { FeatureStatusMap } from "./feature"
import * as k8s from "./k8s"
import { clusterStore } from "../common/cluster-store"
import logger from "./logger"
import { KubeConfig, CoreV1Api } from "@kubernetes/client-node"
import * as fm from "./feature-manager";
import { Kubectl } from "./kubectl";
import { PromiseIpc } from "electron-promise-ipc"
import * as request from "request-promise-native"
import { KubeconfigManager } from "./kubeconfig-manager"
enum ClusterStatus {
AccessGranted = 2,
AccessDenied = 1,
Offline = 0
}
export interface ClusterBaseInfo {
id: string;
kubeConfig: string;
preferences?: ClusterPreferences;
port?: number;
workspace?: string;
}
export interface ClusterInfo extends ClusterBaseInfo {
url: string;
apiUrl: string;
online?: boolean;
accessible?: boolean;
failureReason?: string;
nodes?: number;
version?: string;
distribution?: string;
isAdmin?: boolean;
features?: FeatureStatusMap;
kubeCtl?: Kubectl;
contextName: string;
}
export type ClusterPreferences = {
terminalCWD?: string;
clusterName?: string;
prometheus?: {
namespace: string;
service: string;
port: number;
};
icon?: string;
httpsProxy?: string;
}
export class Cluster implements ClusterInfo {
public id: string;
public workspace: string;
public contextHandler: ContextHandler;
public contextName: string;
public url: string;
public port: number;
public apiUrl: string;
public online: boolean;
public accessible: boolean;
public failureReason: string;
public nodes: number;
public version: string;
public distribution: string;
public isAdmin: boolean;
public features: FeatureStatusMap;
public kubeCtl: Kubectl
public kubeConfig: string;
public eventCount: number;
public preferences: ClusterPreferences;
protected eventPoller: NodeJS.Timeout;
protected promiseIpc = new PromiseIpc({ timeout: 2000 })
protected kubeconfigManager: KubeconfigManager;
constructor(clusterInfo: ClusterBaseInfo) {
if (clusterInfo) Object.assign(this, clusterInfo)
if (!this.preferences) this.preferences = {}
this.kubeconfigManager = new KubeconfigManager(this.kubeConfig)
}
public kubeconfigPath() {
return this.kubeconfigManager.getPath()
}
public async init(kc: KubeConfig) {
this.contextHandler = new ContextHandler(kc, this)
this.contextName = kc.currentContext
this.url = this.contextHandler.url
this.apiUrl = kc.getCurrentCluster().server
await this.contextHandler.init()
}
public stopServer() {
this.contextHandler.stopServer()
clearInterval(this.eventPoller);
}
public async installFeature(name: string, config: any) {
await fm.installFeature(name, this, config)
return this.refreshCluster()
}
public async uninstallFeature(name: string) {
await fm.uninstallFeature(name, this)
return this.refreshCluster()
}
public async refreshCluster() {
clusterStore.reloadCluster(this)
this.contextHandler.setClusterPreferences(this.preferences)
const connectionStatus = await this.getConnectionStatus()
if (connectionStatus == ClusterStatus.AccessGranted) {
this.accessible = true
} else {
this.accessible = false
}
if (connectionStatus > ClusterStatus.Offline) {
this.online = true
} else {
this.online = false
}
if (this.accessible) {
this.distribution = this.detectKubernetesDistribution(this.version)
this.features = await fm.getFeatures(this.contextHandler)
this.isAdmin = await this.isClusterAdmin()
if (this.isAdmin) {
this.nodes = await this.getNodeCount()
}
this.kubeCtl = new Kubectl(this.version)
}
this.eventCount = await this.getEventCount();
}
public updateKubeconfig(kubeconfig: string) {
const storedCluster = clusterStore.getCluster(this.id)
if (!storedCluster) { return }
this.kubeConfig = kubeconfig
this.save()
}
public save() {
clusterStore.storeCluster(this)
}
public toClusterInfo(): ClusterInfo {
const clusterInfo: ClusterInfo = {
id: this.id,
workspace: this.workspace,
url: this.url,
contextName: this.contextHandler.kc.currentContext,
apiUrl: this.apiUrl,
online: this.online,
accessible: this.accessible,
failureReason: this.failureReason,
nodes: this.nodes,
version: this.version,
distribution: this.distribution,
isAdmin: this.isAdmin,
features: this.features,
kubeCtl: this.kubeCtl,
kubeConfig: this.kubeConfig,
preferences: this.preferences
}
return clusterInfo;
}
protected async k8sRequest(path: string, opts?: request.RequestPromiseOptions) {
const options = Object.assign({
json: true, timeout: 10000
}, (opts || {}))
if (!options.headers) { options.headers = {} }
options.headers.host = `${this.id}.localhost:${this.port}`
return request(`http://127.0.0.1:${this.port}/api-kube${path}`, options)
}
protected async getConnectionStatus() {
try {
const response = await this.k8sRequest("/version")
this.version = response.gitVersion
this.failureReason = null
return ClusterStatus.AccessGranted;
} catch(error) {
logger.error(`Failed to connect to cluster ${this.contextName}: ${JSON.stringify(error)}`)
if (error.statusCode) {
if (error.statusCode >= 400 && error.statusCode < 500) {
this.failureReason = "Invalid credentials";
return ClusterStatus.AccessDenied;
} else {
this.failureReason = error.error || error.message;
return ClusterStatus.Offline;
}
} else if (error.failed === true) {
if (error.timedOut === true) {
this.failureReason = "Connection timed out";
return ClusterStatus.Offline;
} else {
this.failureReason = "Failed to fetch credentials";
return ClusterStatus.AccessDenied;
}
}
this.failureReason = error.message;
return ClusterStatus.Offline;
}
}
protected async isClusterAdmin(): Promise<boolean> {
const requestOpts: request.RequestPromiseOptions = {
body: {
kind: "SelfSubjectAccessReview",
apiVersion: "authorization.k8s.io/v1",
spec: {
resourceAttributes: {
namespace: "kube-system",
resource: "*",
verb: "create",
}
}
},
method: "post"
}
try {
const response = await this.k8sRequest("/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", requestOpts)
return response.status.allowed === true
} catch(error) {
logger.error(`failed to request selfSubjectAccessReview: ${error.message}`)
return false
}
}
protected detectKubernetesDistribution(kubernetesVersion: string): string {
if (kubernetesVersion.includes("gke")) {
return "gke"
}
else if (kubernetesVersion.includes("eks")) {
return "eks"
}
else if (kubernetesVersion.includes("IKS")) {
return "iks"
}
else if(this.apiUrl.endsWith("azmk8s.io")) {
return "aks"
}
else if(this.apiUrl.endsWith("k8s.ondigitalocean.com")) {
return "digitalocean"
}
else if (this.contextHandler.contextName.startsWith("minikube")) {
return "minikube"
}
else if (kubernetesVersion.includes("+")) {
return "custom"
}
return "vanilla"
}
protected async getNodeCount() {
try {
const response = await this.k8sRequest("/api/v1/nodes")
return response.items.length
} catch(error) {
logger.debug(`failed to request node list: ${error.message}`)
return null
}
}
public async getEventCount(): Promise<number> {
if (!this.isAdmin) {
return 0;
}
const client = this.contextHandler.kc.makeApiClient(CoreV1Api);
try {
const response = await client.listEventForAllNamespaces(false, null, null, null, 1000);
const uniqEventSources = new Set();
const warnings = response.body.items.filter(e => e.type !== 'Normal');
for (const w of warnings) {
if(w.involvedObject.kind === 'Pod') {
try {
const pod = (await client.readNamespacedPod(w.involvedObject.name, w.involvedObject.namespace)).body;
logger.debug(`checking pod ${w.involvedObject.namespace}/${w.involvedObject.name}`)
if(k8s.podHasIssues(pod)) {
uniqEventSources.add(w.involvedObject.uid);
}
continue;
} catch (error) {
continue;
}
} else {
uniqEventSources.add(w.involvedObject.uid);
}
}
let nodeNotificationCount = 0;
const nodes = (await client.listNode()).body.items;
nodes.map(n => {
nodeNotificationCount = nodeNotificationCount + k8s.getNodeWarningConditions(n).length
});
return uniqEventSources.size + nodeNotificationCount;
} catch (error) {
logger.error("Failed to fetch event count: " + JSON.stringify(error))
return 0;
}
}
}
+245
View File
@@ -0,0 +1,245 @@
import { app } from "electron"
import { KubeConfig } from "@kubernetes/client-node"
import { readFileSync } from "fs"
import * as http from "http"
import { ServerOptions } from "http-proxy"
import * as url from "url"
import { v4 as uuid } from "uuid"
import logger from "./logger"
import { getFreePort } from "./port"
import { LensServer } from "./lens-server"
import { KubeAuthProxy } from "./kube-auth-proxy"
import { Cluster, ClusterPreferences } from "./cluster"
import { userStore } from "../common/user-store"
export class ContextHandler {
public contextName: string
public id: string
public url: string
public kc: KubeConfig
public certData: string
public authCertData: string
public cluster: Cluster
protected apiTarget: ServerOptions
protected proxyTarget: ServerOptions
protected clusterUrl: url.UrlWithStringQuery
protected localServer: LensServer
protected proxyServer: KubeAuthProxy
protected clientCert: string
protected clientKey: string
protected secureApiConnection = true
protected defaultNamespace: string
protected port: number
protected proxyPort: number
protected kubernetesApi: string
protected prometheusPath: string
protected clusterName: string
constructor(kc: KubeConfig, cluster: Cluster) {
this.id = cluster.id
this.kc = new KubeConfig()
this.kc.users = [
{
name: kc.getCurrentUser().name,
token: this.id
}
]
this.kc.contexts = [
{
name: kc.currentContext,
cluster: kc.getCurrentCluster().name,
user: kc.getCurrentUser().name,
namespace: kc.getContextObject(kc.currentContext).namespace
}
]
this.kc.setCurrentContext(kc.currentContext)
this.cluster = cluster
this.clusterUrl = url.parse(kc.getCurrentCluster().server)
this.contextName = kc.currentContext;
this.defaultNamespace = kc.getContextObject(kc.currentContext).namespace
this.url = `http://${this.id}.localhost:${cluster.port}/`
this.kubernetesApi = `http://127.0.0.1:${cluster.port}/${this.id}`
this.setClusterPreferences(cluster.preferences)
this.kc.clusters = [
{
name: kc.getCurrentCluster().name,
server: this.kubernetesApi,
skipTLSVerify: true
}
]
}
public setClusterPreferences(clusterPreferences?: ClusterPreferences) {
if (clusterPreferences && clusterPreferences.prometheus) {
const prom = clusterPreferences.prometheus
this.prometheusPath = `${prom.namespace}/services/${prom.service}:${prom.port}`
} else {
this.prometheusPath = "lens-metrics/services/prometheus:80"
}
if(clusterPreferences && clusterPreferences.clusterName) {
this.clusterName = clusterPreferences.clusterName;
} else {
this.clusterName = this.contextName;
}
}
public async init() {
const currentCluster = this.kc.getCurrentCluster()
if (currentCluster.caFile) {
this.certData = readFileSync(currentCluster.caFile).toString()
} else if (currentCluster.caData) {
this.certData = Buffer.from(currentCluster.caData, "base64").toString("ascii")
}
const user = this.kc.getCurrentUser()
if (user.authProvider && user.authProvider.name === "oidc") {
const authConfig = user.authProvider.config
if (authConfig["idp-certificate-authority"]) {
this.authCertData = readFileSync(authConfig["idp-certificate-authority"]).toString()
} else if (authConfig["idp-certificate-authority-data"]) {
this.authCertData = Buffer.from(authConfig["idp-certificate-authority-data"], "base64").toString("ascii")
}
}
}
public async getApiTarget() {
if (this.apiTarget) { return this.apiTarget }
this.apiTarget = {
changeOrigin: true,
timeout: 30000,
headers: {
"Host": this.clusterUrl.hostname
},
target: {
port: await this.resolveProxyPort(),
protocol: "http://",
host: "localhost",
path: this.clusterUrl.path
},
}
return this.apiTarget
}
public async getProxyTarget() {
if (this.proxyTarget) {
return this.proxyTarget;
}
this.proxyTarget = {
changeOrigin: true,
secure: false,
target: {
host: this.clusterUrl.host,
hostname: "localhost",
path: "/",
port: await this.resolvePort(),
protocol: "http://",
},
}
return this.proxyTarget;
}
protected async resolvePort(): Promise<number> {
if (this.port) return this.port
let serverPort: number = null
try {
serverPort = await getFreePort(49153, 49900) // the proxy will usually already be on 49152 so skip that
} catch(error) {
logger.error(error)
throw(error)
}
this.port = serverPort
return serverPort
}
protected async resolveProxyPort(): Promise<number> {
if (this.proxyPort) return this.proxyPort
let serverPort: number = null
try {
serverPort = await getFreePort(49901, 65535)
} catch(error) {
logger.error(error)
throw(error)
}
this.proxyPort = serverPort
return serverPort
}
public applyHeaders(req: http.IncomingMessage) {
req.headers["authorization"] = `Bearer ${this.id}`
}
public async withTemporaryKubeconfig(callback: (kubeconfig: string) => Promise<any>) {
try {
await callback(this.cluster.kubeconfigPath())
} catch(error) {
throw(error)
}
}
protected initServer(serverUrl: string, port: number) {
const userPrefs = userStore.getPreferences()
const envs = {
KUBE_CLUSTER_URL: serverUrl,
KUBE_CLUSTER_NAME: this.clusterName,
KUBERNETES_TLS_SKIP: "true",
KUBERNETES_NAMESPACE: this.defaultNamespace,
SESSION_SECRET: this.id,
LOCAL_SERVER_PORT: port.toString(),
KUBE_METRICS_URL: `${serverUrl}/api/v1/namespaces/${this.prometheusPath}/proxy`,
STATS_NAMESPACE_DEFAULT: this.prometheusPath.split("/")[0],
CHARTS_ENABLED: "true",
LENS_VERSION: app.getVersion(),
LENS_THEME: `kontena-${userPrefs.colorTheme}`,
NODE_ENV: "production",
}
logger.debug(`spinning up lens-server process with env: ${JSON.stringify(envs)}`)
this.localServer = new LensServer(serverUrl, envs)
}
public async ensureServer() {
if (!this.localServer) {
const currentCluster = this.kc.getCurrentCluster()
const clusterUrl = url.parse(currentCluster.server)
const serverPort = await this.resolvePort()
logger.info(`initializing server for ${clusterUrl.host} on port ${serverPort}`)
this.initServer(this.kubernetesApi, serverPort)
await this.localServer.run()
}
if (!this.proxyServer) {
const proxyPort = await this.resolveProxyPort()
const proxyEnv = Object.assign({}, process.env)
if (this.cluster.preferences && this.cluster.preferences.httpsProxy) {
proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy
}
this.proxyServer = new KubeAuthProxy(this.cluster, proxyPort, proxyEnv)
await this.proxyServer.run()
}
}
public stopServer() {
if (this.localServer) {
this.localServer.exit()
this.localServer = null
}
if (this.proxyServer) {
this.proxyServer.exit()
this.proxyServer = null
}
}
public proxyServerError() {
if (!this.proxyServer) { return null }
return this.proxyServer.lastError
}
}
+49
View File
@@ -0,0 +1,49 @@
import { ContextHandler } from "./context-handler"
import logger from "./logger";
import { Cluster } from "./cluster";
import { Feature, FeatureStatusMap } from "./feature"
import { MetricsFeature } from "../features/metrics"
import { UserModeFeature } from "../features/user-mode"
const ALL_FEATURES: any = {
'metrics': new MetricsFeature(null),
'user-mode': new UserModeFeature(null),
}
export async function getFeatures(clusterContext: ContextHandler): Promise<FeatureStatusMap> {
return new Promise<FeatureStatusMap>(async (resolve, reject) => {
const result: FeatureStatusMap = {};
logger.debug(`features for ${clusterContext.contextName}`);
for (const key in ALL_FEATURES) {
logger.debug(`feature ${key}`);
if (ALL_FEATURES.hasOwnProperty(key)) {
logger.debug("getting feature status...");
const feature = ALL_FEATURES[key] as Feature;
const status = await feature.featureStatus(clusterContext.kc);
result[feature.name] = status
} else {
logger.error("ALL_FEATURES.hasOwnProperty(key) returned FALSE ?!?!?!?!")
}
}
logger.debug(`getFeatures resolving with features: ${JSON.stringify(result)}`);
resolve(result);
});
}
export async function installFeature(name: string, cluster: Cluster, config: any) {
const feature = ALL_FEATURES[name] as Feature
// TODO Figure out how to handle config stuff
await feature.install(cluster)
}
export async function uninstallFeature(name: string, cluster: Cluster) {
const feature = ALL_FEATURES[name] as Feature
await feature.uninstall(cluster)
}
+106
View File
@@ -0,0 +1,106 @@
import * as fs from "fs";
import * as path from "path"
import * as hb from "handlebars"
import { ResourceApplier } from "./resource-applier"
import { KubeConfig, CoreV1Api, Watch } from "@kubernetes/client-node"
import logger from "./logger";
import { Cluster } from "./cluster";
export type FeatureStatus = {
currentVersion: string;
installed: boolean;
latestVersion: string;
canUpgrade: boolean;
// TODO We need bunch of other stuff too: upgradeable, latestVersion, ...
};
export type FeatureStatusMap = {
[name: string]: FeatureStatus;
}
export abstract class Feature {
name: string;
config: any;
latestVersion: string;
constructor(config: any) {
if(config) {
this.config = config;
}
}
// TODO Return types for these?
async install(cluster: Cluster): Promise<boolean> {
return new Promise<boolean>((resolve, reject) => {
// Read and process yamls through handlebar
const resources = this.renderTemplates();
// Apply processed manifests
cluster.contextHandler.withTemporaryKubeconfig(async (kubeconfigPath) => {
const resourceApplier = new ResourceApplier(cluster, kubeconfigPath)
try {
await resourceApplier.kubectlApplyAll(resources)
resolve(true)
} catch(error) {
reject(error)
}
});
});
}
upgrade(): boolean {
return true;
}
abstract async uninstall(cluster: Cluster): Promise<boolean>;
abstract async featureStatus(kc: KubeConfig): Promise<FeatureStatus>;
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 req = nsWatch.watch('/api/v1/namespaces', {resourceVersion: nsVersion, fieldSelector: "metadata.name=lens-metrics"},
(type, obj) => {
if(type === 'DELETED') {
logger.debug(`namespace ${name} finally gone`)
req.abort();
resolve()
}
},
(err) => {
if(err) {
reject(err)
}
});
});
}
protected renderTemplates(): string[] {
console.log("starting to render resources...");
const resources: string[] = [];
fs.readdirSync(this.manifestPath()).forEach((f) => {
const file = path.join(this.manifestPath(), f);
console.log("processing file:", file)
const raw = fs.readFileSync(file);
console.log("raw file loaded");
if(f.endsWith('.hb')) {
console.log("processing HB template");
const template = hb.compile(raw.toString());
resources.push(template(this.config));
console.log("HB template done");
} else {
console.log("using as raw, no HB detected");
resources.push(raw.toString());
}
});
return resources;
}
protected manifestPath() {
return path.join(__dirname, '..', 'features', this.name);
}
}
+11
View File
@@ -0,0 +1,11 @@
import * as fs from "fs"
export function ensureDir(dirname: string) {
if (!fs.existsSync(dirname)) {
fs.mkdirSync(dirname)
}
}
export function randomFileName(name: string) {
return `${Math.random().toString(36).substring(2, 15)}-${Math.random().toString(36).substring(2, 15)}-${name}`
}
+114
View File
@@ -0,0 +1,114 @@
import { LensApiRequest } from "./router"
import { helmService } from "./helm-service"
import { LensApi } from "./lens-api"
import logger from "./logger"
class HelmApi extends LensApi {
public async listCharts(request: LensApiRequest) {
const { response } = request
const charts = await helmService.listCharts()
this.respondJson(response, charts)
}
public async getChart(request: LensApiRequest) {
const { params, query, response } = request
const chart = await helmService.getChart(params.repo, params.chart, query.get("version"))
this.respondJson(response, chart)
}
public async getChartValues(request: LensApiRequest) {
const { params, query, response } = request
const values = await helmService.getChartValues(params.repo, params.chart, query.get("version"))
this.respondJson(response, values)
}
public async installChart(request: LensApiRequest) {
const { payload, cluster, response } = request
try {
const result = await helmService.installChart(cluster, payload)
this.respondJson(response, result, 201)
} catch (error) {
logger.debug(error)
this.respondText(response, error, 422)
}
}
public async updateRelease(request: LensApiRequest) {
const { cluster, params, payload, response } = request
try {
const result = await helmService.updateRelease(cluster, params.release, params.namespace, payload )
this.respondJson(response, result)
} catch (error) {
logger.debug(error)
this.respondText(response, error, 422)
}
}
public async rollbackRelease(request: LensApiRequest) {
const { cluster, params, payload, response } = request
try {
const result = await helmService.rollback(cluster, params.release, params.namespace, payload.revision)
this.respondJson(response, result)
} catch (error) {
logger.debug(error)
this.respondText(response, error)
}
}
public async listReleases(request: LensApiRequest) {
const { cluster, params, response } = request
try {
const result = await helmService.listReleases(cluster, params.namespace)
this.respondJson(response, result)
} catch(error) {
logger.debug(error)
this.respondText(response, error)
}
}
public async getRelease(request: LensApiRequest) {
const { cluster, params, response } = request
try {
const result = await helmService.getRelease(cluster, params.release, params.namespace)
this.respondJson(response, result)
} catch (error) {
logger.debug(error)
this.respondText(response, error, 422)
}
}
public async getReleaseValues(request: LensApiRequest) {
const { cluster, params, response } = request
try {
const result = await helmService.getReleaseValues(cluster, params.release, params.namespace)
this.respondText(response, result)
} catch (error) {
logger.debug(error)
this.respondText(response, error, 422)
}
}
public async getReleaseHistory(request: LensApiRequest) {
const { cluster, params, response } = request
try {
const result = await helmService.getReleaseHistory(cluster, params.release, params.namespace)
this.respondJson(response, result)
} catch (error) {
logger.debug(error)
this.respondText(response, error, 422)
}
}
public async deleteRelease(request: LensApiRequest) {
const { cluster, params, response } = request
try {
const result = await helmService.deleteRelease(cluster, params.release, params.namespace)
this.respondJson(response, result)
} catch (error) {
logger.debug(error)
this.respondText(response, error, 422)
}
}
}
export const helmApi = new HelmApi()
+73
View File
@@ -0,0 +1,73 @@
import * as fs from "fs";
import * as yaml from "js-yaml";
import { HelmRepo, HelmRepoManager } from "./helm-repo-manager"
import logger from "./logger";
import { promiseExec } from "./promise-exec"
import { helmCli } from "./helm-cli"
type CachedYaml = {
entries: any;
}
export class HelmChartManager {
protected cache: any
protected repo: HelmRepo
constructor(repo: HelmRepo){
this.cache = HelmRepoManager.cache
this.repo = repo
}
public async chart(name: string) {
const charts = await this.charts()
return charts[name]
}
public async charts(): Promise<any> {
try {
const cachedYaml = await this.cachedYaml()
return cachedYaml["entries"]
} catch(error) {
logger.error(error)
return []
}
}
public async getReadme(name: string, version = "") {
const helm = await helmCli.binaryPath()
if(version && version != "") {
const { stdout, stderr} = await promiseExec(`"${helm}" show readme ${this.repo.name}/${name} --version ${version}`).catch((error) => { throw(error.stderr)})
return stdout
} else {
const { stdout, stderr} = await promiseExec(`"${helm}" show readme ${this.repo.name}/${name}`).catch((error) => { throw(error.stderr)})
return stdout
}
}
public async getValues(name: string, version = "") {
const helm = await helmCli.binaryPath()
if(version && version != "") {
const { stdout, stderr} = await promiseExec(`"${helm}" show values ${this.repo.name}/${name} --version ${version}`).catch((error) => { throw(error.stderr)})
return stdout
} else {
const { stdout, stderr} = await promiseExec(`"${helm}" show values ${this.repo.name}/${name}`).catch((error) => { throw(error.stderr)})
return stdout
}
}
protected async cachedYaml(): Promise<CachedYaml> {
if (!(this.repo.name in this.cache)) {
const cacheFile = await fs.promises.readFile(this.repo.cacheFilePath, 'utf-8')
const data = yaml.safeLoad(cacheFile)
for(const key in data["entries"]) {
data["entries"][key].forEach((version: any) => {
version['repo'] = this.repo.name
version['created'] = Date.parse(version.created).toString()
})
}
this.cache[this.repo.name] = Buffer.from(JSON.stringify(data))
}
return JSON.parse(this.cache[this.repo.name].toString())
}
}
+31
View File
@@ -0,0 +1,31 @@
import * as path from "path"
import { LensBinary, LensBinaryOpts } from "./lens-binary"
export class HelmCli extends LensBinary {
public constructor(version: string) {
const opts: LensBinaryOpts = {
version,
originalBinaryName: "helm",
newBinaryName: "helm3"
}
super(opts)
}
protected getTarName(): string|null {
return `${this.binaryName}-v${this.binaryVersion}-${this.platformName}-${this.arch}.tar.gz`
}
protected getUrl() {
return `https://get.helm.sh/helm-v${this.binaryVersion}-${this.platformName}-${this.arch}.tar.gz`
}
protected getBinaryPath() {
return path.join(this.dirname, this.platformName+"-"+this.arch, this.binaryName)
}
protected getOriginalBinaryPath() {
return path.join(this.dirname, this.platformName+"-"+this.arch, this.originalBinaryName)
}
}
export const helmCli = new HelmCli("3.1.2")
+112
View File
@@ -0,0 +1,112 @@
import * as tempy from "tempy";
import * as fs from "fs";
import * as yaml from "js-yaml";
import * as camelcaseKeys from "camelcase-keys";
import { promiseExec} from "./promise-exec"
import { helmCli } from "./helm-cli";
import { Cluster } from "./cluster";
export class HelmReleaseManager {
public async listReleases(pathToKubeconfig: string, namespace?: string) {
const helm = await helmCli.binaryPath()
const namespaceFlag = namespace ? `-n ${namespace}` : "--all-namespaces"
const { stdout, stderr } = await promiseExec(`"${helm}" ls --output json ${namespaceFlag} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr)})
const output = JSON.parse(stdout)
if (output.length == 0) {
return output
}
const result: any = []
output.forEach((release: any, index: number) => {
output[index] = camelcaseKeys(release)
});
return output
}
public async installChart(chart: string, values: any, name: string, namespace: string, version: string, pathToKubeconfig: string){
const helm = await helmCli.binaryPath()
const fileName = tempy.file({name: "values.yaml"})
await fs.promises.writeFile(fileName, yaml.safeDump(values))
try {
let generateName = ""
if (!name) {
generateName = "--generate-name"
name = ""
}
const { stdout, stderr } = await promiseExec(`"${helm}" install ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} ${generateName}`).catch((error) => { throw(error.stderr)})
const releaseName = stdout.split("\n")[0].split(' ')[1].trim()
return {
log: stdout,
release: {
name: releaseName,
namespace: namespace
}
}
} finally {
await fs.promises.unlink(fileName)
}
}
public async upgradeRelease(name: string, chart: string, values: any, namespace: string, version: string, cluster: Cluster){
const helm = await helmCli.binaryPath()
const fileName = tempy.file({name: "values.yaml"})
await fs.promises.writeFile(fileName, yaml.safeDump(values))
try {
const { stdout, stderr } = await promiseExec(`"${helm}" upgrade ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${cluster.kubeconfigPath()}`).catch((error) => { throw(error.stderr)})
return {
log: stdout,
release: this.getRelease(name, namespace, cluster)
}
} finally {
await fs.promises.unlink(fileName)
}
}
public async getRelease(name: string, namespace: string, cluster: Cluster) {
const helm = await helmCli.binaryPath()
const {stdout, stderr} = await promiseExec(`"${helm}" status ${name} --output json --namespace ${namespace} --kubeconfig ${cluster.kubeconfigPath()}`).catch((error) => { throw(error.stderr)})
const release = JSON.parse(stdout)
release.resources = await this.getResources(name, namespace, cluster)
return release
}
public async deleteRelease(name: string, namespace: string, pathToKubeconfig: string) {
const helm = await helmCli.binaryPath()
const { stdout, stderr } = await promiseExec(`"${helm}" delete ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr)})
return stdout
}
public async getValues(name: string, namespace: string, pathToKubeconfig: string) {
const helm = await helmCli.binaryPath()
const { stdout, stderr } = await promiseExec(`"${helm}" get values ${name} --all --output yaml --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr)})
return stdout
}
public async getHistory(name: string, namespace: string, pathToKubeconfig: string) {
const helm = await helmCli.binaryPath()
const {stdout, stderr} = await promiseExec(`"${helm}" history ${name} --output json --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr)})
return JSON.parse(stdout)
}
public async rollback(name: string, namespace: string, revision: number, pathToKubeconfig: string) {
const helm = await helmCli.binaryPath()
const {stdout, stderr} = await promiseExec(`"${helm}" rollback ${name} ${revision} --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr)})
return stdout
}
protected async getResources(name: string, namespace: string, cluster: Cluster) {
const helm = await helmCli.binaryPath()
const kubectl = await cluster.kubeCtl.kubectlPath()
const pathToKubeconfig = cluster.kubeconfigPath()
const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectl}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`).catch((error) => {
return { stdout: JSON.stringify({items: []})}
})
return stdout
}
}
export const releaseManager = new HelmReleaseManager()
+155
View File
@@ -0,0 +1,155 @@
import * as fs from "fs";
import logger from "./logger";
import * as yaml from "js-yaml";
import { promiseExec } from "./promise-exec";
import { helmCli } from "./helm-cli";
type HelmEnv = {
[key: string]: string | undefined;
}
export type HelmRepo = {
name: string;
url: string;
cacheFilePath?: string;
}
export class HelmRepoManager {
private static instance: HelmRepoManager;
public static cache = {}
protected helmEnv: HelmEnv
protected initialized: boolean
static getInstance(): HelmRepoManager {
if(!HelmRepoManager.instance) {
HelmRepoManager.instance = new HelmRepoManager()
}
return HelmRepoManager.instance;
}
private constructor() {
// use singleton getInstance()
}
public async init() {
const helm = await helmCli.binaryPath()
if (!this.initialized) {
this.helmEnv = await this.parseHelmEnv()
await this.update()
this.initialized = true
}
}
protected async parseHelmEnv() {
const helm = await helmCli.binaryPath()
const { stdout } = await promiseExec(`"${helm}" env`).catch((error) => { throw(error.stderr)})
const lines = stdout.split(/\r?\n/) // split by new line feed
const env: HelmEnv = {}
lines.forEach((line: string) => {
const [key, value] = line.split("=")
if (key && value) {
env[key] = value.replace(/"/g, "") // strip quotas
}
})
return env
}
public async repositories(): Promise<Array<HelmRepo>> {
if(!this.initialized) {
await this.init()
}
const repositoryFilePath = this.helmEnv.HELM_REPOSITORY_CONFIG
const repoFile = await fs.promises.readFile(repositoryFilePath, 'utf8').catch(async (error) => {
return null
})
if(!repoFile) {
await this.addRepo({ name: "stable", url: "https://kubernetes-charts.storage.googleapis.com/" })
return await this.repositories()
}
try {
const repositories = yaml.safeLoad(repoFile)
const result = repositories['repositories'].map((repository: HelmRepo) => {
return {
name: repository.name,
url: repository.url,
cacheFilePath: `${this.helmEnv.HELM_REPOSITORY_CACHE}/${repository['name']}-index.yaml`
}
});
if (result.length == 0) {
await this.addRepo({ name: "stable", url: "https://kubernetes-charts.storage.googleapis.com/" })
return await this.repositories()
}
return result
} catch (error) {
logger.debug(error)
return []
}
}
public async repository(name: string) {
const repositories = await this.repositories()
return repositories.find((repo: HelmRepo) => {
return repo.name == name
})
}
public async update() {
const helm = await helmCli.binaryPath()
logger.debug(`${helm} repo update`)
const {stdout } = await promiseExec(`"${helm}" repo update`).catch((error) => { return { stdout: error.stdout } })
return stdout
}
protected async addRepositories(repositories: HelmRepo[]){
const currentRepositories = await this.repositories()
repositories.forEach(async (repo: HelmRepo) => {
try {
const repoExists = currentRepositories.find((currentRepo: HelmRepo) => {
return currentRepo.url == repo.url
})
if(!repoExists) {
await this.addRepo(repo)
}
}
catch(error) {
logger.error(JSON.stringify(error))
}
});
}
protected async pruneRepositories(repositoriesToKeep: HelmRepo[]) {
const repositories = await this.repositories()
repositories.filter((repo: HelmRepo) => {
return repositoriesToKeep.find((repoToKeep: HelmRepo) => {
return repo.name == repoToKeep.name
}) === undefined
}).forEach(async (repo: HelmRepo) => {
try {
const output = await this.removeRepo(repo)
logger.debug(output)
} catch(error) {
logger.error(error)
}
})
}
public async addRepo(repository: HelmRepo) {
const helm = await helmCli.binaryPath()
logger.debug(`${helm} repo add ${repository.name} ${repository.url}`)
const {stdout } = await promiseExec(`"${helm}" repo add ${repository.name} ${repository.url}`).catch((error) => { throw(error.stderr)})
return stdout
}
public async removeRepo(repository: HelmRepo): Promise<string> {
const helm = await helmCli.binaryPath()
logger.debug(`${helm} repo remove ${repository.name} ${repository.url}`)
const { stdout, stderr } = await promiseExec(`"${helm}" repo remove ${repository.name}`).catch((error) => { throw(error.stderr)})
return stdout
}
}
export const repoManager = HelmRepoManager.getInstance()
+105
View File
@@ -0,0 +1,105 @@
import { Cluster } from "./cluster";
import logger from "./logger";
import { repoManager } from "./helm-repo-manager";
import { HelmChartManager } from "./helm-chart-manager";
import { releaseManager } from "./helm-release-manager";
class HelmService {
public async installChart(cluster: Cluster, data: {chart: string; values: {}; name: string; namespace: string; version: string}) {
const installResult = await releaseManager.installChart(data.chart, data.values, data.name, data.namespace, data.version, cluster.kubeconfigPath())
return installResult
}
public async listCharts() {
const charts: any = {}
await repoManager.init()
const repositories = await repoManager.repositories()
for (const repo of repositories) {
charts[repo.name] = {}
const manager = new HelmChartManager(repo)
let entries = await manager.charts()
entries = this.excludeDeprecated(entries)
for(const key in entries) {
entries[key] = entries[key][0]
}
charts[repo.name] = entries
}
return charts
}
public async getChart(repoName: string, chartName: string, version = "") {
const result = {
readme: "",
versions: {}
}
const repo = await repoManager.repository(repoName)
const chartManager = new HelmChartManager(repo)
const chart = await chartManager.chart(chartName)
result.readme = await chartManager.getReadme(chartName, version)
result.versions = chart
return result
}
public async getChartValues(repoName: string, chartName: string, version = "") {
const repo = await repoManager.repository(repoName)
const chartManager = new HelmChartManager(repo)
return chartManager.getValues(chartName, version)
}
public async listReleases(cluster: Cluster, namespace: string = null) {
await repoManager.init()
const releases = await releaseManager.listReleases(cluster.kubeconfigPath(), namespace)
return releases
}
public async getRelease(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Fetch release")
const release = await releaseManager.getRelease(releaseName, namespace, cluster)
return release
}
public async getReleaseValues(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Fetch release values")
const values = await releaseManager.getValues(releaseName, namespace, cluster.kubeconfigPath())
return values
}
public async getReleaseHistory(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Fetch release history")
const history = await releaseManager.getHistory(releaseName, namespace, cluster.kubeconfigPath())
return(history)
}
public async deleteRelease(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Delete release")
const release = await releaseManager.deleteRelease(releaseName, namespace, cluster.kubeconfigPath())
return release
}
public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: {chart: string; values: {}; version: string}) {
logger.debug("Upgrade release")
const release = await releaseManager.upgradeRelease(releaseName, data.chart, data.values, namespace, data.version, cluster)
return release
}
public async rollback(cluster: Cluster, releaseName: string, namespace: string, revision: number) {
logger.debug("Rollback release")
const output = await releaseManager.rollback(releaseName, namespace, revision, cluster.kubeconfigPath())
return({ message: output })
}
protected excludeDeprecated(entries: any) {
for(const key in entries) {
entries[key] = entries[key].filter((entry: any) => {
if(Array.isArray(entry)) {
return entry[0]['deprecated'] != true
}
return entry["deprecated"] != true
})
}
return entries
}
}
export const helmService = new HelmService()
+127
View File
@@ -0,0 +1,127 @@
import "../common/system-ca"
import { app, dialog, protocol } from "electron"
import { PromiseIpc } from "electron-promise-ipc"
import * as path from "path"
import { format as formatUrl } from "url"
import logger from "./logger"
import initMenu from "./menu"
import * as proxy from "./proxy"
import { WindowManager } from "./window-manager";
import { clusterStore } from "../common/cluster-store"
import { tracker } from "./tracker"
import { ClusterManager } from "./cluster-manager";
import AppUpdater from "./app-updater"
import { shellSync } from "./shell-sync"
import { getFreePort } from "./port"
import { mangleProxyEnv } from "./proxy-env"
import { findMainWebContents } from "./webcontents"
import { HelmRepoManager, HelmRepo } from "./helm-repo-manager"
mangleProxyEnv()
if (app.commandLine.getSwitchValue("proxy-server") !== "") {
process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server")
}
const isDevelopment = process.env.NODE_ENV !== "production"
const promiseIpc = new PromiseIpc({ timeout: 2000 })
let windowManager: WindowManager = null;
let clusterManager: ClusterManager = null;
const vmURL = (isDevelopment) ? `http://localhost:${process.env.ELECTRON_WEBPACK_WDS_PORT}` : formatUrl({
pathname: path.join(__dirname, "index.html"),
protocol: "file",
slashes: true,
})
async function main() {
await shellSync()
const updater = new AppUpdater()
updater.start();
tracker.event("app", "start");
protocol.registerFileProtocol('store', (request, callback) => {
const url = request.url.substr(8)
callback( path.normalize(`${app.getPath("userData")}/${url}`) )
}, (error) => {
if (error) console.error('Failed to register protocol')
})
let port: number = null
// find free port
try {
port = await getFreePort(49152, 65535)
} catch (error) {
logger.error(error)
await dialog.showErrorBox("Lens Error", "Could not find a free port for the cluster proxy")
app.quit();
}
// create cluster manager
clusterManager = new ClusterManager(clusterStore.getAllClusterObjects(), port)
// run proxy
try {
proxy.listen(port, clusterManager)
} catch (error) {
logger.error(`Could not start proxy (127.0.0:${port}): ${error.message}`)
await dialog.showErrorBox("Lens Error", `Could not start proxy (127.0.0:${port}): ${error.message || "unknown error"}`)
app.quit();
}
// boot windowmanager
windowManager = new WindowManager();
windowManager.showMain(vmURL)
initMenu({
logoutHook: async () => {
// IPC send needs webContents as we're sending it to renderer
promiseIpc.send('logout', findMainWebContents(), {}).then((data: any) => {
logger.debug("logout IPC sent");
})
},
showPreferencesHook: async () => {
// IPC send needs webContents as we're sending it to renderer
promiseIpc.send('navigate', findMainWebContents(), {name: 'preferences-page'}).then((data: any) => {
logger.debug("navigate: preferences IPC sent");
})
},
addClusterHook: async () => {
promiseIpc.send('navigate', findMainWebContents(), { name: "add-cluster-page" }).then((data: any) => {
logger.debug("navigate: add-cluster-page IPC sent");
})
},
showWhatsNewHook: async () => {
promiseIpc.send('navigate', findMainWebContents(), { name: "whats-new-page" }).then((data: any) => {
logger.debug("navigate: whats-new-page IPC sent");
})
},
clusterSettingsHook: async () => {
promiseIpc.send('navigate', findMainWebContents(), { name: "cluster-settings-page" }).then((data: any) => {
logger.debug("navigate: cluster-settings-page IPC sent");
})
},
}, promiseIpc);
}
app.on("ready", main)
app.on('window-all-closed', function() {
// On OS X it is common for applications and their menu bar
// to stay active until the user quits explicitly with Cmd + Q
if (process.platform != 'darwin') {
app.quit();
} else {
windowManager = null
if (clusterManager) clusterManager.stop()
}
})
app.on("activate", () => {
if (!windowManager) {
logger.debug("activate main window")
windowManager = new WindowManager(false)
windowManager.showMain(vmURL)
}
})
app.on("will-quit", async (event) => {
event.preventDefault(); // To allow mixpanel sending to be executed
if (clusterManager) clusterManager.stop()
app.exit(0);
})
+155
View File
@@ -0,0 +1,155 @@
import * as k8s from "@kubernetes/client-node"
import * as os from "os"
import { all } from "q";
import * as yaml from "js-yaml"
import { V1beta1ValidatingWebhookConfiguration } from "@kubernetes/client-node";
import logger from "./logger";
const kc = new k8s.KubeConfig()
function resolveTilde(filePath: string) {
if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) {
return filePath.replace("~", os.homedir());
}
return filePath;
}
export function loadConfig(kubeconfig: string): k8s.KubeConfig {
if (kubeconfig) {
kc.loadFromFile(resolveTilde(kubeconfig))
} else {
kc.loadFromDefault();
}
return kc
}
/**
* KubeConfig is valid when there's atleast one of each defined:
* - User
* - Cluster
* - Context
*
* @param config KubeConfig to check
*/
export function valideConfig(config: k8s.KubeConfig): boolean {
logger.debug(`validating kube config: ${JSON.stringify(config)}`)
if(!config.users || config.users.length == 0) {
throw new Error("No users provided in config")
}
if(!config.clusters || config.clusters.length == 0) {
throw new Error("No clusters provided in config")
}
if(!config.contexts || config.contexts.length == 0) {
throw new Error("No contexts provided in config")
}
return true
}
/**
* Breaks kube config into several configs. Each context as it own KubeConfig object
*
* @param configString yaml string of kube config
*/
export function splitConfig(kubeConfig: k8s.KubeConfig): k8s.KubeConfig[] {
const configs: k8s.KubeConfig[] = []
if(!kubeConfig.contexts) {
return configs;
}
kubeConfig.contexts.forEach(ctx => {
const kc = new k8s.KubeConfig();
kc.clusters = [kubeConfig.getCluster(ctx.cluster)].filter(n => n);
kc.users = [kubeConfig.getUser(ctx.user)].filter(n => n)
kc.contexts = [kubeConfig.getContextObject(ctx.name)].filter(n => n)
kc.setCurrentContext(ctx.name);
configs.push(kc);
});
return configs;
}
/**
* Loads KubeConfig from a yaml string and breaks it into several configs. Each context
*
* @param configString yaml string of kube config
*/
export function loadAndSplitConfig(configString: string): k8s.KubeConfig[] {
const allConfigs = new k8s.KubeConfig();
allConfigs.loadFromString(configString);
return splitConfig(allConfigs);
}
export function dumpConfigYaml(kc: k8s.KubeConfig): string {
const config = {
apiVersion: "v1",
kind: "Config",
preferences: {},
'current-context': kc.currentContext,
clusters: kc.clusters.map(c => {
return {
name: c.name,
cluster: {
'certificate-authority-data': c.caData,
'certificate-authority': c.caFile,
server: c.server,
'insecure-skip-tls-verify': c.skipTLSVerify
}
}
}),
contexts: kc.contexts.map(c => {
return {
name: c.name,
context: {
cluster: c.cluster,
user: c.user,
namespace: c.namespace
}
}
}),
users: kc.users.map(u => {
return {
name: u.name,
user: {
'client-certificate-data': u.certData,
'client-certificate': u.certFile,
'client-key-data': u.keyData,
'client-key': u.keyFile,
'auth-provider': u.authProvider,
exec: u.exec,
token: u.token,
username: u.username,
password: u.password
}
}
})
}
console.log("dumping kc:", config);
// skipInvalid: true makes dump ignore undefined values
return yaml.safeDump(config, {skipInvalid: true});
}
export function podHasIssues(pod: k8s.V1Pod) {
// Logic adapted from dashboard
const notReady = !!pod.status.conditions.find(condition => {
return condition.type == "Ready" && condition.status !== "True"
});
return (
notReady ||
pod.status.phase !== "Running" ||
pod.spec.priority > 500000 // We're interested in high prio pods events regardless of their running status
)
}
// Logic adapted from dashboard
// see: https://github.com/kontena/kontena-k8s-dashboard/blob/7d8f9cb678cc817a22dd1886c5e79415b212b9bf/client/api/endpoints/nodes.api.ts#L147
export function getNodeWarningConditions(node: k8s.V1Node) {
return node.status.conditions.filter(c =>
c.status.toLowerCase() === "true" && c.type !== "Ready" && c.type !== "HostUpgrades"
)
}
+109
View File
@@ -0,0 +1,109 @@
import { spawn, ChildProcess } from "child_process"
import logger from "./logger"
import * as tcpPortUsed from "tcp-port-used"
import { Kubectl, bundledKubectl } from "./kubectl"
import { Cluster } from "./cluster"
import { readFileSync, watch } from "fs"
import { PromiseIpc } from "electron-promise-ipc"
import { findMainWebContents } from "./webcontents"
import * as url from "url"
export class KubeAuthProxy {
public lastError: string
protected cluster: Cluster
protected env: NodeJS.ProcessEnv = null
protected proxyProcess: ChildProcess
protected port: number
protected kubectl: Kubectl
protected promiseIpc: any
constructor(cluster: Cluster, port: number, env: NodeJS.ProcessEnv) {
this.env = env
this.port = port
this.cluster = cluster
this.kubectl = bundledKubectl
this.promiseIpc = new PromiseIpc({ timeout: 2000 })
}
public async run(): Promise<void> {
if (this.proxyProcess) {
return new Promise((resolve, reject) => {
resolve()
})
}
const proxyBin = await this.kubectl.kubectlPath()
const configWatcher = watch(this.cluster.kubeconfigPath(), (eventType: string, filename: string) => {
if (eventType === "change") {
const kc = readFileSync(this.cluster.kubeconfigPath()).toString()
if (kc.trim().length > 0) { // Prevent updating empty configs back to store
this.cluster.updateKubeconfig(kc)
} else {
logger.warn(`kubeconfig watch on ${this.cluster.kubeconfigPath()} resulted into empty config, ignoring...`)
}
}
})
configWatcher.on("error", () => {})
const clusterUrl = url.parse(this.cluster.apiUrl)
let args = [
"proxy",
"-p", this.port.toString(),
"--kubeconfig", this.cluster.kubeconfigPath(),
"--accept-hosts", clusterUrl.hostname,
]
if (process.env.DEBUG_PROXY === "true") {
args = args.concat(["-v", "9"])
}
this.proxyProcess = spawn(proxyBin, args, {
env: this.env
})
this.proxyProcess.on("exit", (code) => {
logger.error(`proxy ${this.cluster.contextName} exited with code ${code}`)
this.sendIpcLogMessage( `proxy exited with code ${code}`, "stderr")
this.proxyProcess = null
configWatcher.close()
})
this.proxyProcess.stdout.on('data', (data) => {
let logItem = data.toString()
if (logItem.startsWith("Starting to serve on")) {
logItem = "Authentication proxy started\n"
}
logger.debug(`proxy ${this.cluster.contextName} stdout: ${logItem}`)
this.sendIpcLogMessage(logItem, "stdout")
})
this.proxyProcess.stderr.on('data', (data) => {
this.lastError = this.parseError(data.toString())
logger.debug(`proxy ${this.cluster.contextName} stderr: ${data}`)
this.sendIpcLogMessage(data.toString(), "stderr")
})
return tcpPortUsed.waitUntilUsed(this.port, 500, 10000)
}
protected parseError(data: string) {
const error = data.split("http: proxy error:").slice(1).join("").trim()
let errorMsg = error
const jsonError = error.split("Response: ")[1]
if (jsonError) {
try {
const parsedError = JSON.parse(jsonError)
errorMsg = parsedError.error_description || parsedError.error || jsonError
} catch(_) {
errorMsg = jsonError.trim()
}
}
return errorMsg
}
protected sendIpcLogMessage(data: string, stream: string) {
this.promiseIpc.send(`kube-auth:${this.cluster.id}`, findMainWebContents(), { data: data, stream: stream })
}
public exit() {
if (this.proxyProcess) {
logger.debug(`Stopping local proxy: ${this.cluster.contextName}`)
this.proxyProcess.kill()
this.proxyProcess = null
}
}
}
+32
View File
@@ -0,0 +1,32 @@
import { app } from "electron"
import * as fs from "fs"
import { ensureDir, randomFileName} from "./file-helpers"
import logger from "./logger"
export class KubeconfigManager {
protected configDir = app.getPath("temp")
protected kubeconfig: string
protected tempFile: string
constructor(kubeconfig: string) {
this.kubeconfig = kubeconfig
this.tempFile = this.createTemporaryKubeconfig()
}
public getPath() {
return this.tempFile
}
protected createTemporaryKubeconfig(): string {
ensureDir(this.configDir)
const path = `${this.configDir}/${randomFileName("kubeconfig")}`
logger.debug('Creating temporary kubeconfig: ' + path)
fs.writeFileSync(path, this.kubeconfig)
return path
}
public unlink() {
logger.debug('Deleting temporary kubeconfig: ' + this.tempFile)
fs.unlinkSync(this.tempFile)
}
}
+293
View File
@@ -0,0 +1,293 @@
import { app, remote } from "electron"
import * as path from "path"
import * as fs from "fs"
import * as request from "request"
import * as requestPromise from "request-promise-native"
import logger from "./logger"
import { ensureDir, pathExists } from "fs-extra"
import * as md5File from "md5-file"
import { globalRequestOpts } from "../common/request"
import * as lockFile from "proper-lockfile"
import { helmCli } from "./helm-cli"
const kubectlMap: Map<string, string> = new Map([
["1.7", "1.8.15"],
["1.8", "1.9.10"],
["1.9", "1.10.13"],
["1.10", "1.11.10"],
["1.11", "1.12.10"],
["1.12", "1.13.12"],
["1.13", "1.13.12"],
["1.14", "1.14.10"],
["1.15", "1.15.10"],
["1.16", "1.16.7"],
["1.17", "1.17.3"],
["1.18", "1.18.0-beta.1"]
])
const initScriptVersionString = "# lens-initscript v3\n"
const isDevelopment = process.env.NODE_ENV !== "production"
let bundledPath: string = null
if(isDevelopment) {
bundledPath = path.join(process.cwd(), "binaries", "client", process.platform, process.arch, "kubectl")
} else {
bundledPath = path.join(process.resourcesPath, process.arch, "kubectl")
}
if(process.platform === "win32") bundledPath = `${bundledPath}.exe`
export class Kubectl {
public kubectlVersion: string
protected directory: string
protected url: string
protected path: string
protected dirname: string
public static readonly kubectlDir = path.join((app || remote.app).getPath("userData"), "binaries", "kubectl")
public static readonly bundledKubectlPath = bundledPath
public static readonly bundledKubectlVersion: string = require("../../package.json").config.bundledKubectlVersion
private static bundledInstance: Kubectl;
// Returns the single bundled Kubectl instance
public static bundled() {
if(!Kubectl.bundledInstance) Kubectl.bundledInstance = new Kubectl(Kubectl.bundledKubectlVersion)
return Kubectl.bundledInstance
}
constructor(clusterVersion: string) {
const versionParts = /^v?(\d+\.\d+)(.*)/.exec(clusterVersion)
const minorVersion = versionParts[1]
/* minorVersion is the first two digits of kube server version
if the version map includes that, use that version, if not, fallback to the exact x.y.z of kube version */
if(kubectlMap.has(minorVersion)) {
this.kubectlVersion = kubectlMap.get(minorVersion)
logger.debug("Set kubectl version " + this.kubectlVersion + " for cluster version " + clusterVersion + " using version map")
} else {
this.kubectlVersion = versionParts[1] + versionParts[2]
logger.debug("Set kubectl version " + this.kubectlVersion + " for cluster version " + clusterVersion + " using fallback")
}
let arch = null
if(process.arch == "x64") {
arch = "amd64"
} else if(process.arch == "x86" || process.arch == "ia32") {
arch = "386"
} else {
arch = process.arch
}
const platformName = process.platform === "win32" ? "windows" : process.platform
const binaryName = process.platform === "win32" ? "kubectl.exe" : "kubectl"
this.url = `https://storage.googleapis.com/kubernetes-release/release/v${this.kubectlVersion}/bin/${platformName}/${arch}/${binaryName}`
this.dirname = path.normalize(path.join(Kubectl.kubectlDir, this.kubectlVersion))
this.path = path.join(this.dirname, binaryName)
}
public async kubectlPath(): Promise<string> {
try {
await this.ensureKubectl()
return this.path
} catch(err) {
logger.error("Failed to ensure kubectl, fallback to the bundled version")
logger.error(err)
return Kubectl.bundledKubectlPath
}
}
public async binDir() {
try {
await this.ensureKubectl()
return this.dirname
} catch(err) {
logger.error(err)
return ""
}
}
protected async urlEtag() {
const response = await requestPromise({
method: "HEAD",
uri: this.url,
resolveWithFullResponse: true,
timeout: 4000,
...this.getRequestOpts()
}).catch((error) => { logger.error(error) })
if (response && response.headers["etag"]) {
return response.headers["etag"].replace(/"/g, "")
}
return ""
}
public async checkBinary(checkTag = true) {
const exists = await pathExists(this.path)
if (exists) {
if (!checkTag) {
return true
}
const hash = md5File.sync(this.path)
const etag = await this.urlEtag()
if (etag === "") {
logger.debug("Cannot resolve kubectl remote etag")
return true
}
if (hash == etag) {
logger.debug("Kubectl md5sum matches the remote etag")
return true
}
logger.error("Kubectl md5sum " + hash + " does not match the remote etag " + etag + ", unlinking and downloading again")
await fs.promises.unlink(this.path)
}
return false
}
protected async checkBundled(): Promise<boolean> {
if(this.kubectlVersion === Kubectl.bundledKubectlVersion) {
try {
const exist = await pathExists(this.path)
if (!exist) {
await fs.promises.copyFile(Kubectl.bundledKubectlPath, this.path)
await fs.promises.chmod(this.path, 0o755)
}
return true
} catch(err) {
logger.error("Could not copy the bundled kubectl to app-data: " + err)
return false
}
} else {
return false
}
}
protected async ensureKubectl(): Promise<boolean> {
await ensureDir(this.dirname, 0o755)
return lockFile.lock(this.dirname).then(async (release) => {
logger.debug(`Acquired a lock for ${this.kubectlVersion}`)
const bundled = await this.checkBundled()
const isValid = await this.checkBinary(!bundled)
if(!isValid) {
await this.downloadKubectl().catch((error) => { logger.error(error) });
}
await this.writeInitScripts().catch((error) => { logger.error("Failed to write init scripts"); logger.error(error) })
logger.debug(`Releasing lock for ${this.kubectlVersion}`)
release()
return true
}).catch((e) => {
logger.error(`Failed to get a lock for ${this.kubectlVersion}`)
logger.error(e)
return false
})
}
public async downloadKubectl() {
await ensureDir(path.dirname(this.path), 0o755)
logger.info(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`)
return new Promise((resolve, reject) => {
const stream = request({
gzip: true,
...this.getRequestOpts()
})
const file = fs.createWriteStream(this.path)
stream.on("complete", () => {
logger.debug("kubectl binary download finished")
file.end(() => {})
})
stream.on("error", (error) => {
logger.error(error)
fs.unlink(this.path, () => {})
reject(error)
})
file.on("close", () => {
logger.debug("kubectl binary download closed")
fs.chmod(this.path, 0o755, () => {})
resolve()
})
stream.pipe(file)
})
}
protected async scriptIsLatest(scriptPath: string) {
const scriptExists = await pathExists(scriptPath)
if(!scriptExists) return false
try {
const filehandle = await fs.promises.open(scriptPath, 'r')
const buffer = Buffer.alloc(40)
await filehandle.read(buffer, 0, 40, 0)
await filehandle.close()
return buffer.toString().startsWith(initScriptVersionString)
} catch (err) {
logger.error(err)
return false
}
}
protected async writeInitScripts() {
const helmPath = helmCli.getBinaryDir()
const fsPromises = fs.promises;
const bashScriptPath = path.join(this.dirname, '.bash_set_path')
const bashScriptIsLatest = await this.scriptIsLatest(bashScriptPath)
if(!bashScriptIsLatest) {
let bashScript = "" + initScriptVersionString
bashScript += "tempkubeconfig=\"$KUBECONFIG\"\n"
bashScript += "test -f \"/etc/profile\" && . \"/etc/profile\"\n"
bashScript += "if test -f \"$HOME/.bash_profile\"; then\n"
bashScript += " . \"$HOME/.bash_profile\"\n"
bashScript += "elif test -f \"$HOME/.bash_login\"; then\n"
bashScript += " . \"$HOME/.bash_login\"\n"
bashScript += "elif test -f \"$HOME/.profile\"; then\n"
bashScript += " . \"$HOME/.profile\"\n"
bashScript += "fi\n"
bashScript += `export PATH="${this.dirname}:${helmPath}:$PATH"\n`
bashScript += "export KUBECONFIG=\"$tempkubeconfig\"\n"
bashScript += "unset tempkubeconfig\n"
await fsPromises.writeFile(bashScriptPath, bashScript.toString(), { mode: 0o644 })
}
const zshScriptPath = path.join(this.dirname, '.zlogin')
const zshScriptIsLatest = await this.scriptIsLatest(zshScriptPath)
if(!zshScriptIsLatest) {
let zshScript = "" + initScriptVersionString
zshScript += "tempkubeconfig=\"$KUBECONFIG\"\n"
// restore previous ZDOTDIR
zshScript += "export ZDOTDIR=\"$OLD_ZDOTDIR\"\n"
// source all the files
zshScript += "test -f \"$OLD_ZDOTDIR/.zshenv\" && . \"$OLD_ZDOTDIR/.zshenv\"\n"
zshScript += "test -f \"$OLD_ZDOTDIR/.zprofile\" && . \"$OLD_ZDOTDIR/.zprofile\"\n"
zshScript += "test -f \"$OLD_ZDOTDIR/.zlogin\" && . \"$OLD_ZDOTDIR/.zlogin\"\n"
zshScript += "test -f \"$OLD_ZDOTDIR/.zshrc\" && . \"$OLD_ZDOTDIR/.zshrc\"\n"
// voodoo to replace any previous occurences of kubectl path in the PATH
zshScript += `kubectlpath=\"${this.dirname}"\n`
zshScript += `helmpath=\"${helmPath}"\n`
zshScript += "p=\":$kubectlpath:\"\n"
zshScript += "d=\":$PATH:\"\n"
zshScript += "d=${d//$p/:}\n"
zshScript += "d=${d/#:/}\n"
zshScript += "export PATH=\"$kubectlpath:$helmpath:${d/%:/}\"\n"
zshScript += "export KUBECONFIG=\"$tempkubeconfig\"\n"
zshScript += "unset tempkubeconfig\n"
zshScript += "unset OLD_ZDOTDIR\n"
await fsPromises.writeFile(zshScriptPath, zshScript.toString(), { mode: 0o644 })
}
}
protected getRequestOpts() {
return globalRequestOpts({
url: this.url
})
}
}
const bundledKubectl = Kubectl.bundled()
export { bundledKubectl }
+17
View File
@@ -0,0 +1,17 @@
import * as http from "http";
export abstract class LensApi {
protected respondJson(res: http.ServerResponse, content: {}, status = 200) {
this.respond(res, JSON.stringify(content), "application/json", status)
}
protected respondText(res: http.ServerResponse, content: string, status = 200) {
this.respond(res, content, "text/plain", status)
}
protected respond(res: http.ServerResponse, content: string, contentType: string, status = 200) {
res.setHeader("Content-Type", contentType)
res.statusCode = status
res.end(content)
}
}
+167
View File
@@ -0,0 +1,167 @@
import { app, remote } from "electron"
import * as path from "path"
import * as fs from "fs"
import * as request from "request"
import logger from "./logger"
import { ensureDir, pathExists } from "fs-extra"
import * as tar from "tar"
import { userStore } from "../common/user-store"
import { globalRequestOpts} from "../common/request"
export type LensBinaryOpts = {
version: string;
originalBinaryName: string;
newBinaryName?: string;
}
export class LensBinary {
public binaryVersion: string
protected directory: string
protected url: string
protected path: string;
protected tarPath: string;
protected dirname: string
protected binaryName: string
protected platformName: string
protected arch: string
protected originalBinaryName: string
constructor(opts: LensBinaryOpts) {
this.originalBinaryName = opts.originalBinaryName
this.binaryName = opts.newBinaryName || opts.originalBinaryName
this.binaryVersion = opts.version
let arch = null
if(process.arch == "x64") {
arch = "amd64"
} else if(process.arch == "x86" || process.arch == "ia32") {
arch = "386"
} else {
arch = process.arch
}
this.arch = arch
const binaryDir = path.join((app || remote.app).getPath("userData"), "binaries", this.binaryName)
this.platformName = process.platform === "win32" ? "windows" : process.platform
if (process.platform === "win32") {
this.binaryName = this.binaryName+".exe"
this.originalBinaryName = this.originalBinaryName+".exe"
}
this.dirname = path.normalize(path.join(binaryDir, this.binaryVersion))
const tarName = this.getTarName()
if (tarName) {
this.tarPath = path.join(this.dirname, tarName)
}
}
public async binaryPath() {
await this.ensureBinary()
return this.getBinaryPath()
}
protected getTarName(): string|null {
return null
}
protected getUrl() {
return ""
}
protected getBinaryPath() {
return ""
}
protected getOriginalBinaryPath() {
return ""
}
public getBinaryDir() {
return path.dirname(this.getBinaryPath())
}
public async binDir() {
try {
await this.ensureBinary()
return this.dirname
} catch(err) {
logger.error(err)
return ""
}
}
protected async checkBinary() {
const exists = await pathExists(this.getBinaryPath())
return exists
}
protected async ensureBinary() {
const isValid = await this.checkBinary()
if(!isValid) {
await this.downloadBinary().catch((error) => { logger.error(error) });
if (this.tarPath) await this.untarBinary()
if(this.originalBinaryName != this.binaryName ) await this.renameBinary()
}
}
protected async untarBinary() {
return new Promise<void>((resolve, reject) => {
logger.debug(`Extracting ${this.originalBinaryName} binary`)
tar.x({
file: this.tarPath,
cwd: this.dirname
}).then((_ => {
resolve()
}))
})
}
protected async renameBinary() {
return new Promise<void>((resolve, reject) => {
logger.debug(`Renaming ${this.originalBinaryName} binary to ${this.binaryName}`)
fs.rename(this.getOriginalBinaryPath(), this.getBinaryPath(), (err) => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
}
public async downloadBinary() {
const binaryPath = this.tarPath || this.getBinaryPath()
await ensureDir(this.getBinaryDir(), 0o755)
const file = fs.createWriteStream(binaryPath)
const url = this.getUrl()
logger.info(`Downloading ${this.originalBinaryName} ${this.binaryVersion} from ${url} to ${binaryPath}`)
const requestOpts: request.UriOptions & request.CoreOptions = {
uri: url,
gzip: true
}
const stream = request(globalRequestOpts(requestOpts))
stream.on("complete", () => {
logger.info(`${this.originalBinaryName} binary download finished`)
file.end(() => {})
})
stream.on("error", (error) => {
logger.error(error)
fs.unlink(this.path, () => {})
throw(error)
})
return new Promise((resolve, reject) => {
file.on("close", () => {
logger.debug(`${this.originalBinaryName} binary download closed`)
if(!this.tarPath) fs.chmod(binaryPath, 0o755, () => {})
resolve()
})
stream.pipe(file)
})
}
}
+63
View File
@@ -0,0 +1,63 @@
import * as path from "path"
import { spawn, ChildProcess } from "child_process"
import logger from "./logger"
import * as tcpPortUsed from "tcp-port-used"
declare const __static: string;
const isDevelopment = process.env.NODE_ENV !== "production"
let serverPath: string = null
if (isDevelopment) {
serverPath = path.join(process.cwd(), "binaries", "server", process.platform, "lens-server")
} else {
serverPath = path.join(process.resourcesPath, "lens-server")
if (process.platform !== "win32") {
serverPath = `${serverPath}.txt`
}
}
if (process.platform === "win32") {
serverPath = `${serverPath}-${process.arch}.exe`
}
export class LensServer {
protected serverUrl: string = null
protected env: NodeJS.ProcessEnv = null
protected localServer: ChildProcess
constructor(serverUrl: string, env: NodeJS.ProcessEnv) {
this.serverUrl = serverUrl
this.env = env
}
public async run(): Promise<void> {
if (this.localServer) {
return new Promise((resolve, reject) => {
resolve()
})
}
this.localServer = spawn(serverPath, [], {
env: this.env,
cwd: __static
})
this.localServer.on("exit", (code) => {
logger.error(`server ${this.serverUrl} exited with code ${code}`)
this.localServer = null
})
this.localServer.stdout.on('data', (data) => {
logger.debug(`server ${this.serverUrl} stdout: ${data}`)
})
this.localServer.stderr.on('data', (data) => {
logger.debug(`server ${this.serverUrl} stderr: ${data}`)
})
return tcpPortUsed.waitUntilUsed(parseInt(this.env.LOCAL_SERVER_PORT), 500, 10000)
}
public exit() {
if (this.localServer) {
logger.debug(`Stopping local server: ${this.serverUrl}`)
this.localServer.kill()
this.localServer = null
}
}
}
+20
View File
@@ -0,0 +1,20 @@
import * as winston from "winston"
const options = {
colorize: true,
handleExceptions: false,
json: false,
level: process.env.DEBUG === "true" ? "debug" : "info",
}
const logger = winston.createLogger({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple(),
),
transports: [
new winston.transports.Console(options),
],
});
export default logger
+212
View File
@@ -0,0 +1,212 @@
import {app, dialog, Menu, MenuItemConstructorOptions, shell, webContents, BrowserWindow, MenuItem} from "electron"
export interface MenuOptions {
logoutHook: any;
addClusterHook: any;
clusterSettingsHook: any;
showWhatsNewHook: any;
showPreferencesHook: any;
// all the above are really () => void type functions
}
function setClusterSettingsEnabled(enabled: boolean) {
const isMac = process.platform === 'darwin';
const menuIndex = isMac ? 1 : 0
Menu.getApplicationMenu().items[menuIndex].submenu.items[1].enabled = enabled
}
function showAbout(_menuitem: MenuItem, browserWindow: BrowserWindow) {
const appDetails = [
`Version: ${app.getVersion()}`,
]
appDetails.push(`Copyright 2020 Lakend Labs, Inc.`)
let title = "Lens"
if (process.platform === "win32") {
title = ` ${title}`
}
dialog.showMessageBoxSync(browserWindow, {
title,
type: "info",
buttons: ["Close"],
message: `Lens`,
detail: appDetails.join("\r\n")
})
}
/**
* Constructs the menu based on the example at: https://electronjs.org/docs/api/menu#main-process
* Menu items are constructed piece-by-piece to have slightly better control on individual sub-menus
*
* @param ipc the main promiceIpc handle. Needed to be able to hook IPC sending into logout click handler.
*/
export default function initMenu(opts: MenuOptions, promiseIpc: any) {
const isMac = process.platform === 'darwin';
const isDevelopment = process.env.NODE_ENV === 'development';
const mt: MenuItemConstructorOptions[] = [];
const macAppMenu: MenuItemConstructorOptions = {
label: app.getName(),
submenu: [
{
label: "About Lens",
click: showAbout
},
{ type: 'separator' },
{
label: 'Preferences',
click: opts.showPreferencesHook,
enabled: true
},
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' }
]
};
if(isMac) {
mt.push(macAppMenu);
}
let fileMenu: MenuItemConstructorOptions;
if(isMac) {
fileMenu = {
label: 'File',
submenu: [{
label: 'Add Cluster...',
click: opts.addClusterHook,
},
{
label: 'Cluster Settings',
click: opts.clusterSettingsHook,
enabled: false
}
]
}
} else {
fileMenu = {
label: 'File',
submenu: [
{
label: 'Add Cluster...',
click: opts.addClusterHook,
},
{
label: 'Cluster Settings',
click: opts.clusterSettingsHook,
enabled: false
},
{ type: 'separator' },
{
label: 'Preferences',
click: opts.showPreferencesHook,
enabled: true
},
{ type: 'separator' },
{ role: 'quit' }
]
}
}
mt.push(fileMenu);
const editMenu: MenuItemConstructorOptions = {
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'delete' },
{ type: 'separator' },
{ role: 'selectAll' },
]
};
mt.push(editMenu);
const viewMenu: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
label: 'Back',
accelerator: 'CmdOrCtrl+[',
click () {
webContents.getFocusedWebContents().executeJavaScript('window.history.back()')
}
},
{
label: 'Forward',
accelerator: 'CmdOrCtrl+]',
click () {
webContents.getFocusedWebContents().executeJavaScript('window.history.forward()')
}
},
{ role: 'reload' },
{ role: 'forceReload' },
...(isDevelopment ? [
{ role: 'toggleDevTools'} as MenuItemConstructorOptions,
{
label: 'Open Dashboard Devtools',
click() {
webContents.getFocusedWebContents().openDevTools()
}
}
] : []),
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' }
]
};
mt.push(viewMenu);
const helpMenu: MenuItemConstructorOptions = {
role: 'help',
submenu: [
{
label: 'License',
click: async () => {
shell.openExternal('https://lakendlabs.com/licenses/lens-eula.md');
},
},
{
label: 'Community Slack',
click: async () => {
shell.openExternal('https://join.slack.com/t/kontenacommunity/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI');
},
},
{
label: 'Report an Issue',
click: async () => {
shell.openExternal('https://github.com/kontena/lens/issues');
},
},
{
label: "What's new?",
click: opts.showWhatsNewHook,
},
...(process.platform !== "darwin" ? [{
label: "About Lens",
click: showAbout
} as MenuItemConstructorOptions] : [])
]
};
mt.push(helpMenu);
const menu = Menu.buildFromTemplate(mt);
Menu.setApplicationMenu(menu);
promiseIpc.on("enableClusterSettingsMenuItem", (clusterId: string) => {
setClusterSettingsEnabled(true)
});
promiseIpc.on("disableClusterSettingsMenuItem", () => {
setClusterSettingsEnabled(false)
});
};
+143
View File
@@ -0,0 +1,143 @@
import * as WebSocket from "ws"
import * as pty from "node-pty"
import { ShellSession } from "./shell-session";
import { v4 as uuid } from "uuid"
import * as k8s from "@kubernetes/client-node"
import logger from "./logger";
import { KubeConfig, V1Pod } from "@kubernetes/client-node";
import { tracker } from "./tracker"
import { Cluster, ClusterPreferences } from "./cluster"
export class NodeShellSession extends ShellSession {
protected nodeName: string;
protected podId: string
protected kc: KubeConfig
constructor(socket: WebSocket, pathToKubeconfig: string, cluster: Cluster, nodeName: string) {
super(socket, pathToKubeconfig, cluster)
this.nodeName = nodeName
this.podId = `node-shell-${uuid()}`
this.kc = cluster.contextHandler.kc
}
public async open() {
const shell = await this.kubectl.kubectlPath()
let args = []
if (this.createNodeShellPod(this.podId, this.nodeName)) {
await this.waitForRunningPod(this.podId).catch((error) => {
this.exit(1001)
})
}
args = ["exec", "-i", "-t", "-n", "kube-system", this.podId, "--", "sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"]
const shellEnv = this.getShellEnv()
this.shellProcess = pty.spawn(shell, args, {
cols: 80,
cwd: this.cwd() || shellEnv["HOME"],
env: shellEnv,
name: "xterm-256color",
rows: 30,
});
this.pipeStdout()
this.pipeStdin()
this.closeWebsocketOnProcessExit()
this.exitProcessOnWebsocketClose()
tracker.event("node-shell", "open")
}
protected exit(code = 1000) {
if (this.podId) {
this.deleteNodeShellPod()
}
super.exit(code)
}
protected async createNodeShellPod(podId: string, nodeName: string) {
const kc = this.getKubeConfig();
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
const pod = {
metadata: {
name: podId,
namespace: "kube-system"
},
spec: {
restartPolicy: "Never",
terminationGracePeriodSeconds: 0,
hostPID: true,
hostIPC: true,
hostNetwork: true,
tolerations: [{
operator: "Exists"
}],
containers: [{
name: "shell",
image: "docker.io/alpine:3.9",
securityContext: {
privileged: true,
},
command: ["nsenter"],
args: ["-t", "1", "-m", "-u", "-i", "-n", "sleep", "14000"]
}],
nodeSelector: {
"kubernetes.io/hostname": nodeName
}
}
} as k8s.V1Pod;
await k8sApi.createNamespacedPod("kube-system", pod).catch((error) => {
logger.error(error)
return false
})
return true
}
protected getKubeConfig() {
if (this.kc) {
return this.kc
}
this.kc = new k8s.KubeConfig();
this.kc.loadFromFile(this.kubeconfigPath)
return this.kc
}
protected waitForRunningPod(podId: string) {
return new Promise<boolean>((resolve, reject) => {
const kc = this.getKubeConfig();
const watch = new k8s.Watch(kc);
const req = watch.watch(`/api/v1/namespaces/kube-system/pods`, {},
// callback is called for each received object.
(_type, obj) => {
if (obj.metadata.name == podId && obj.status.phase === "Running") {
resolve(true)
}
},
// done callback is called if the watch terminates normally
(err) => {
logger.error(err)
reject(false)
}
);
setTimeout(() => { req.abort(); reject(false); }, 120 * 1000);
})
}
protected deleteNodeShellPod() {
const kc = this.getKubeConfig();
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
k8sApi.deleteNamespacedPod(this.podId, "kube-system")
}
}
export async function open(socket: WebSocket, pathToKubeconfig: string, cluster: Cluster, nodeName?: string): Promise<ShellSession> {
return new Promise(async(resolve, reject) => {
let shell = null
if (nodeName) {
shell = new NodeShellSession(socket, pathToKubeconfig, cluster, nodeName)
}
else {
shell = new ShellSession(socket, pathToKubeconfig, cluster)
}
shell.open()
resolve(shell)
})
}
+27
View File
@@ -0,0 +1,27 @@
import logger from "./logger"
import { createServer } from "net"
// Adapted from https://gist.github.com/mikeal/1840641#gistcomment-2896667
function checkPort(port: number) {
const server = createServer()
server.unref()
return new Promise((resolve, reject) =>
server
.on('error', error => reject(error))
.on('listening', () => server.close(() => resolve(port)))
.listen({host: "127.0.0.1", port: port}))
}
export async function getFreePort(firstPort: number, lastPort: number): Promise<number> {
let port = firstPort
while(true) {
try {
logger.debug("Checking port " + port + " availability ...")
await checkPort(port)
return(port)
} catch(error) {
if(++port > lastPort) throw("Could not find a free port")
}
}
}
+4
View File
@@ -0,0 +1,4 @@
import * as util from "util"
import { exec } from "child_process";
export const promiseExec = util.promisify(exec)
+18
View File
@@ -0,0 +1,18 @@
import { app } from "electron"
const switchValue = app.commandLine.getSwitchValue("proxy-server")
export function mangleProxyEnv() {
let httpsProxy = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || ""
delete process.env.HTTPS_PROXY
delete process.env.HTTP_PROXY
if (switchValue !== "") {
httpsProxy = switchValue
}
if (httpsProxy !== "") {
process.env.APP_HTTPS_PROXY = httpsProxy
}
}
+203
View File
@@ -0,0 +1,203 @@
import * as http from "http";
import * as httpProxy from "http-proxy";
import { Socket } from "net";
import * as url from "url";
import * as WebSocket from "ws"
import { ContextHandler } from "./context-handler";
import logger from "./logger"
import * as shell from "./node-shell-session"
import { ClusterManager } from "./cluster-manager"
import { Router } from "./router"
export class LensProxy {
public static readonly localShellSessions = true
public port: number;
protected clusterUrl: url.UrlWithStringQuery
protected clusterManager: ClusterManager
protected retryCounters: Map<string, number> = new Map()
protected router: Router
constructor(port: number, clusterManager: ClusterManager) {
this.port = port
this.clusterManager = clusterManager
this.router = new Router()
}
public run() {
const proxyServer = this.buildProxyServer();
proxyServer.listen(this.port, "127.0.0.1")
}
protected buildProxyServer() {
const proxy = this.createProxy();
const proxyServer = http.createServer(function(req: http.IncomingMessage, res: http.ServerResponse) {
this.handleRequest(proxy, req, res);
}.bind(this));
proxyServer.on("upgrade", function(req: http.IncomingMessage, socket: Socket, head: Buffer) {
if (this.isRemoteShellRequired(req)) {
this.proxyWsUpgrade(proxy, req, socket, head)
} else {
this.handleWsUpgrade(req, socket, head)
}
}.bind(this));
proxyServer.on("error", (err) => {
logger.error(err)
});
return proxyServer;
}
protected createProxy() {
const proxy = httpProxy.createProxyServer();
proxy.on("proxyRes", (proxyRes, req, res) => {
if (proxyRes.statusCode === 502) {
const cluster = this.clusterManager.getClusterForRequest(req)
if (cluster && cluster.contextHandler.proxyServerError()) {
res.writeHead(proxyRes.statusCode, {
"Content-Type": "text/plain"
})
res.end(cluster.contextHandler.proxyServerError().toString())
return
}
}
if (req.method !== "GET") {
return
}
const key = `${req.headers.host}${req.url}`
if (this.retryCounters.has(key)) {
logger.debug("Resetting proxy retry cache for url: " + key)
this.retryCounters.delete(key)
}
})
proxy.on("error", (error, req, res, target) => {
if (target) {
logger.debug("Failed proxy to target: " + JSON.stringify(target))
if (req.method === "GET" && (!res.statusCode || res.statusCode >= 500)) {
const retryCounterKey = `${req.headers.host}${req.url}`
const retryCount = this.retryCounters.get(retryCounterKey) || 0
if (retryCount < 20) {
logger.debug("Retrying proxy request to url: " + retryCounterKey)
setTimeout(() => {
this.retryCounters.set(retryCounterKey, retryCount + 1)
this.handleRequest(proxy, req, res)
}, (250 * retryCount))
}
}
//return
}
res.writeHead(500, {
'Content-Type': 'text/plain'
})
res.end('Oops, something went wrong.')
})
return proxy;
}
protected createWsListener() {
const ws = new WebSocket.Server({ noServer: true})
ws.on("connection", ((con: WebSocket, req: http.IncomingMessage) => {
const cluster = this.clusterManager.getClusterForRequest(req)
const contextHandler = cluster.contextHandler
const nodeParam = this.getNodeParam(req.url)
contextHandler.withTemporaryKubeconfig((kubeconfigPath) => {
return new Promise<boolean>(async (resolve, reject) => {
const shellSession = await shell.open(con, kubeconfigPath, cluster, nodeParam)
shellSession.on("exit", () => {
resolve(true)
})
})
})
}).bind(this))
return ws
}
protected getNodeParam(requestUrl: string) {
const reqUrl = url.parse(requestUrl, true)
const urlParams = reqUrl.query
let nodeParam: string = null
for (const [key, value] of Object.entries(urlParams)) {
if (key === "node") {
nodeParam = value.toString()
}
}
return nodeParam
}
protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ContextHandler): Promise<httpProxy.ServerOptions> {
if (req.url.startsWith("/api-kube/")) {
delete req.headers.authorization
req.url = req.url.replace("/api-kube", "")
return await contextHandler.getApiTarget()
} else {
return await contextHandler.getProxyTarget()
}
}
protected async handleRequest(proxy: httpProxy, req: http.IncomingMessage, res: http.ServerResponse) {
const cluster = this.clusterManager.getClusterForRequest(req)
if (!cluster) {
logger.error("Got request to unknown cluster")
logger.debug(req.headers.host + req.url)
res.statusCode = 503
res.end()
return
}
const contextHandler = cluster.contextHandler
try {
contextHandler.applyHeaders(req)
} catch (error) {
res.statusCode = 503
res.end()
return
}
contextHandler.ensureServer().then(async () => {
if (await this.router.route(cluster, req, res)) return
const proxyTarget = await this.getProxyTarget(req, contextHandler)
proxy.web(req, res, proxyTarget)
})
}
protected async proxyWsUpgrade(proxy: httpProxy, req: http.IncomingMessage, socket: Socket, head: Buffer) {
const cluster = this.clusterManager.getClusterForRequest(req)
const contextHandler = cluster.contextHandler
contextHandler.applyHeaders(req);
const reqUrl = url.parse(req.url, true)
const urlParams = reqUrl.query
for (const [key, value] of Object.entries(urlParams)) {
if (key !== "token") {
req.headers["x-lens-param-" + key] = value
}
}
proxy.ws(req, socket, head, await contextHandler.getProxyTarget());
}
protected async handleWsUpgrade(req: http.IncomingMessage, socket: Socket, head: Buffer) {
const wsServer = this.createWsListener();
const cluster = this.clusterManager.getClusterForRequest(req)
const contextHandler = cluster.contextHandler
contextHandler.applyHeaders(req);
wsServer.handleUpgrade(req, socket, head, (con) => {
wsServer.emit("connection", con, req);
});
}
protected isRemoteShellRequired(req: http.IncomingMessage) {
if (!LensProxy.localShellSessions) {
return true
}
return false;
}
}
export function listen(port: number, clusterManager: ClusterManager) {
const proxyServer = new LensProxy(port, clusterManager)
proxyServer.run();
return proxyServer;
}
+17
View File
@@ -0,0 +1,17 @@
import { LensApiRequest } from "./router"
import * as resourceApplier from "./resource-applier"
import { LensApi } from "./lens-api"
class ResourceApplierApi extends LensApi {
public async applyResource(request: LensApiRequest) {
const { response, cluster, payload } = request
try {
const resource = await resourceApplier.apply(cluster, cluster.kubeconfigPath(), payload)
this.respondJson(response, [resource], 200)
} catch(error) {
this.respondText(response, error, 422)
}
}
}
export const resourceApplierApi = new ResourceApplierApi()
+101
View File
@@ -0,0 +1,101 @@
import { exec } from "child_process";
import * as fs from "fs";
import * as yaml from "js-yaml";
import * as path from "path";
import * as tempy from "tempy";
import logger from "./logger"
import { Cluster } from "./cluster";
import { tracker } from "./tracker";
type KubeObject = {
status: {};
metadata?: {
resourceVersion: number;
annotations?: {
"kubectl.kubernetes.io/last-applied-configuration": string;
};
};
}
export class ResourceApplier {
protected kubeconfigPath: string;
protected cluster: Cluster
constructor(cluster: Cluster, pathToKubeconfig: string) {
this.kubeconfigPath = pathToKubeconfig
this.cluster = cluster
}
public async apply(resource: any): Promise<string> {
this.sanitizeObject((resource as KubeObject))
try {
tracker.event("resource", "apply")
return await this.kubectlApply(yaml.safeDump(resource))
} catch(error) {
throw (error)
}
}
protected async kubectlApply(content: string): Promise<string> {
const kubectl = await this.cluster.kubeCtl.kubectlPath()
return new Promise<string>((resolve, reject) => {
const fileName = tempy.file({name: "resource.yaml"})
fs.writeFileSync(fileName, content)
const cmd = `"${kubectl}" apply --kubeconfig ${this.kubeconfigPath} -o json -f ${fileName}`
logger.debug("shooting manifests with: " + cmd);
const execEnv: NodeJS.ProcessEnv = Object.assign({}, process.env)
const httpsProxy = this.cluster.preferences?.httpsProxy
if (httpsProxy) {
execEnv["HTTPS_PROXY"] = httpsProxy
}
exec(cmd, { env: execEnv },
(error, stdout, stderr) => {
if (stderr != "") {
fs.unlinkSync(fileName)
reject(stderr)
return
}
fs.unlinkSync(fileName)
resolve(JSON.parse(stdout))
})
})
}
public async kubectlApplyAll(resources: string[]): Promise<string> {
const kubectl = await this.cluster.kubeCtl.kubectlPath()
return new Promise<string>((resolve, reject) => {
const tmpDir = tempy.directory()
// Dump each resource into tmpDir
for (const i in resources) {
fs.writeFileSync(path.join(tmpDir, `${i}.yaml`), resources[i])
}
const cmd = `"${kubectl}" apply --kubeconfig ${this.kubeconfigPath} -o json -f ${tmpDir}`
console.log("shooting manifests with:", cmd);
exec(cmd, (error, stdout, stderr) => {
if(error) {
reject("Error applying manifests:" + error);
}
if (stderr != "") {
reject(stderr)
return
}
resolve(stdout)
})
})
}
protected sanitizeObject(resource: KubeObject) {
delete resource['status']
if (resource['metadata']) {
if (resource['metadata']['annotations'] && resource['metadata']['annotations']['kubectl.kubernetes.io/last-applied-configuration']) {
delete resource['metadata']['annotations']['kubectl.kubernetes.io/last-applied-configuration']
}
delete resource['metadata']['resourceVersion']
}
}
}
export async function apply(cluster: Cluster, pathToKubeconfig: string, resource: any) {
const resourceApplier = new ResourceApplier(cluster, pathToKubeconfig)
return await resourceApplier.apply(resource)
}
+85
View File
@@ -0,0 +1,85 @@
import * as http from "http";
import { Cluster } from "./cluster";
import { helmApi } from "./helm-api"
import { resourceApplierApi } from "./resource-applier-api"
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Call = require('@hapi/call');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Subtext = require('@hapi/subtext');
interface RouteParams {
[key: string]: string | undefined;
}
export type LensApiRequest = {
cluster: Cluster;
payload: any;
raw: {
req: http.IncomingMessage;
};
params: RouteParams;
response: http.ServerResponse;
query: URLSearchParams;
path: string;
}
export class Router {
protected router: any
public constructor() {
this.router = new Call.Router();
this.addRoutes()
}
public async route(cluster: Cluster, req: http.IncomingMessage, res: http.ServerResponse) {
const url = new URL(req.url, "http://localhost")
const path = url.pathname
const method = req.method.toLowerCase()
const matchingRoute = this.router.route(method, path)
if (matchingRoute.isBoom !== true) { // route() returns error if route not found -> object.isBoom === true
const request = await this.getRequest({ req, res, cluster, url, params: matchingRoute.params })
await matchingRoute.route(request)
return true
} else {
return false
}
}
protected async getRequest(opts: { req: http.IncomingMessage; res: http.ServerResponse; cluster: Cluster; url: URL; params: RouteParams }) {
const { req, res, url, cluster, params } = opts
const { payload } = await Subtext.parse(req, null, { parse: true, output: 'data' });
const request: LensApiRequest = {
cluster: cluster,
path: url.pathname,
raw: {
req: req,
},
response: res,
query: url.searchParams,
payload: payload,
params: params
}
return request
}
protected addRoutes() {
// Helm API
this.router.add({ method: 'get', path: '/api-helm/v2/charts' }, helmApi.listCharts.bind(helmApi))
this.router.add({ method: 'get', path: '/api-helm/v2/charts/{repo}/{chart}' }, helmApi.getChart.bind(helmApi))
this.router.add({ method: 'get', path: '/api-helm/v2/charts/{repo}/{chart}/values' }, helmApi.getChartValues.bind(helmApi))
this.router.add({ method: 'post', path: '/api-helm/v2/releases' }, helmApi.installChart.bind(helmApi))
this.router.add({ method: 'put', path: '/api-helm/v2/releases/{namespace}/{release}' }, helmApi.updateRelease.bind(helmApi))
this.router.add({ method: 'put', path: '/api-helm/v2/releases/{namespace}/{release}/rollback' }, helmApi.rollbackRelease.bind(helmApi))
this.router.add({ method: 'get', path: '/api-helm/v2/releases/{namespace?}' }, helmApi.listReleases.bind(helmApi))
this.router.add({ method: 'get', path: '/api-helm/v2/releases/{namespace}/{release}' }, helmApi.getRelease.bind(helmApi))
this.router.add({ method: 'get', path: '/api-helm/v2/releases/{namespace}/{release}/values' }, helmApi.getReleaseValues.bind(helmApi))
this.router.add({ method: 'get', path: '/api-helm/v2/releases/{namespace}/{release}/history' }, helmApi.getReleaseHistory.bind(helmApi))
this.router.add({ method: 'delete', path: '/api-helm/v2/releases/{namespace}/{release}' }, helmApi.deleteRelease.bind(helmApi))
// Resource Applier API
this.router.add({ method: 'post', path: '/api-resource/stack' }, resourceApplierApi.applyResource.bind(resourceApplierApi))
}
}
+161
View File
@@ -0,0 +1,161 @@
import * as pty from "node-pty"
import * as WebSocket from "ws"
import { EventEmitter } from "events";
import * as path from "path"
import shellEnv = require("shell-env")
import { app } from "electron"
import { Kubectl } from "./kubectl"
import { tracker } from "./tracker"
import { Cluster, ClusterPreferences } from "./cluster"
import { helmCli } from "./helm-cli"
export class ShellSession extends EventEmitter {
protected websocket: WebSocket
protected shellProcess: pty.IPty
protected kubeconfigPath: string
protected nodeShellPod: string;
protected kubectl: Kubectl;
protected kubectlBinDir: string;
protected helmBinDir: string;
protected preferences: ClusterPreferences;
constructor(socket: WebSocket, pathToKubeconfig: string, cluster: Cluster) {
super()
this.websocket = socket
this.kubeconfigPath = pathToKubeconfig
this.kubectl = new Kubectl(cluster.version)
this.preferences = cluster.preferences || {}
}
public async open() {
this.kubectlBinDir = await this.kubectl.binDir()
this.helmBinDir = helmCli.getBinaryDir()
await helmCli.binaryPath()
const env = this.getShellEnv()
const shell = env.PTYSHELL
const args = await this.getShellArgs(shell)
this.shellProcess = pty.spawn(shell, args, {
cols: 80,
cwd: this.cwd() || env.HOME,
env: env,
name: "xterm-256color",
rows: 30,
});
this.pipeStdout()
this.pipeStdin()
this.closeWebsocketOnProcessExit()
this.exitProcessOnWebsocketClose()
tracker.event("shell", "open")
}
protected cwd(): string {
if(!this.preferences || !this.preferences.terminalCWD || this.preferences.terminalCWD === "") {
return null
}
return this.preferences.terminalCWD
}
protected async getShellArgs(shell: string): Promise<Array<string>> {
switch(path.basename(shell)) {
case "powershell.exe":
return ["-NoExit", "-command", `& {Set-Location $Env:USERPROFILE; $Env:PATH="${this.kubectlBinDir};${this.helmBinDir};$Env:PATH"}`]
break
case "bash":
return ["--init-file", path.join(this.kubectlBinDir, '.bash_set_path')]
break
case "fish":
return ["--login", "--init-command", `export PATH="${this.kubectlBinDir}:${this.helmBinDir}:$PATH"; export KUBECONFIG="${this.kubeconfigPath}"`]
break
case "zsh":
return ["--login"]
default:
return []
}
}
protected getShellEnv() {
const env = JSON.parse(JSON.stringify(shellEnv.sync()))
const pathStr = [this.kubectlBinDir, this.helmBinDir, process.env.PATH].join(path.delimiter)
if(process.platform === "win32") {
env["SystemRoot"] = process.env.SystemRoot
env["PTYSHELL"] = "powershell.exe"
env["PATH"] = pathStr
} else if(typeof(process.env.SHELL) != "undefined") {
env["PTYSHELL"] = process.env.SHELL
env["PATH"] = pathStr
} else {
env["PTYSHELL"] = "" // blank runs the system default shell
}
if(path.basename(env["PTYSHELL"]) === "zsh") {
env["OLD_ZDOTDIR"] = env.ZDOTDIR || env.HOME
env["ZDOTDIR"] = this.kubectlBinDir
}
env["PTYPID"] = process.pid.toString()
env["KUBECONFIG"] = this.kubeconfigPath
env["TERM_PROGRAM"] = app.getName()
env["TERM_PROGRAM_VERSION"] = app.getVersion()
if (this.preferences.httpsProxy) {
env["HTTPS_PROXY"] = this.preferences.httpsProxy
}
if (env.DEBUG) { // do not pass debug option to bash
delete env["DEBUG"]
}
return(env)
}
protected pipeStdout() {
// send shell output to websocket
this.shellProcess.on("data", ((data: string) => {
this.sendResponse(data)
}).bind(this));
}
protected pipeStdin() {
// write websocket messages to shellProcess
this.websocket.on("message", function(data: string) {
const message = Buffer.from(data.slice(1, data.length), "base64").toString()
switch (data[0]) {
case "0":
this.shellProcess.write(message)
break;
case "4":
const resizeMsgObj = JSON.parse(message)
this.shellProcess.resize(resizeMsgObj["Width"], resizeMsgObj["Height"])
break;
case "9":
this.token = message
this.emit('newToken', this.token)
break;
}
}.bind(this))
}
protected exit(code = 1000) {
this.websocket.close(code)
this.emit('exit')
}
protected closeWebsocketOnProcessExit() {
this.shellProcess.on("exit", (_code) => {
this.exit()
});
}
protected exitProcessOnWebsocketClose() {
this.websocket.on("close", () => {
if (this.shellProcess) {
this.shellProcess.kill();
}
})
}
protected sendResponse(msg: string) {
this.websocket.send("1" + Buffer.from(msg).toString("base64"))
}
}
+18
View File
@@ -0,0 +1,18 @@
import shellEnv = require("shell-env")
import logger from "./logger"
export async function shellSync() {
const env = await shellEnv()
// Overwrite PATH on darwin
if (process.env.NODE_ENV === "production" && process.platform === "darwin") {
process.env["PATH"] = env.PATH
}
let key = null
for(key in env) {
if(!env.hasOwnProperty(key) || process.env[key]) continue // skip existing and prototype keys
logger.debug("Imported " + key + " from login shell to process environment")
process.env[key] = env[key]
}
}
+4
View File
@@ -0,0 +1,4 @@
import { Tracker } from "../common/tracker"
import { app, remote } from "electron"
export const tracker = new Tracker(app || remote.app);
+7
View File
@@ -0,0 +1,7 @@
import { webContents } from "electron"
/**
* Helper to find the correct web contents handle for main window
*/
export function findMainWebContents() {
return webContents.getAllWebContents().find(w => w.getType() === "window");
}
+91
View File
@@ -0,0 +1,91 @@
import { BrowserWindow, shell, Menu } from "electron"
import { PromiseIpc } from "electron-promise-ipc"
import * as windowStateKeeper from "electron-window-state"
import * as path from "path"
import { tracker } from "./tracker";
declare const __static: string;
export class WindowManager {
public mainWindow: BrowserWindow = null;
public splashWindow: BrowserWindow = null;
protected promiseIpc: any
protected windowState: windowStateKeeper.State;
constructor(showSplash = true) {
this.promiseIpc = new PromiseIpc({ timeout: 2000 })
// Manage main window size&position with persistence
this.windowState = windowStateKeeper({
defaultHeight: 900,
defaultWidth: 1440,
});
this.splashWindow = new BrowserWindow({
width: 500,
height: 300,
backgroundColor: "#1e2124",
center: true,
frame: false,
resizable: false,
show: false
})
if (showSplash) {
this.splashWindow.loadFile(path.join(__static, "/splash.html"))
this.splashWindow.show()
}
this.mainWindow = new BrowserWindow({
x: this.windowState.x,
y: this.windowState.y,
width: this.windowState.width,
height: this.windowState.height,
backgroundColor: "#1e2124",
titleBarStyle: "hidden",
webPreferences: {
nodeIntegration: true,
webviewTag: true
},
show: false
});
// Hook window state manager into window lifecycle
this.windowState.manage(this.mainWindow);
// handle close event
this.mainWindow.on("close", () => {
this.mainWindow = null;
})
// handle external links
this.mainWindow.webContents.on("will-navigate", (event, link) => {
if (link.startsWith("http://localhost")) {
return;
}
event.preventDefault();
shell.openExternal(link);
})
// handle developer console
if (process.env.NODE_ENV !== "production") {
this.mainWindow.webContents.on("devtools-opened", () => {
if (this.mainWindow.getBrowserView()) {
this.mainWindow.getBrowserView().webContents.openDevTools({mode: "detach"})
}
})
}
this.mainWindow.on("focus", () => {
tracker.event("app", "focus")
})
}
public showMain(url: string) {
this.mainWindow.loadURL( url ).then(() => {
this.splashWindow.hide()
this.splashWindow.loadURL("data:text/html;charset=utf-8,").then(() => {
this.splashWindow.close()
this.mainWindow.show()
})
})
}
}
+37
View File
@@ -0,0 +1,37 @@
<template>
<div id="app">
<div class="draggable-top" />
<div id="lens-container" />
<div class="main-view" :class="{ 'menu-visible': isMenuVisible }">
<main-menu v-if="isMenuVisible" />
<router-view />
</div>
<bottom-bar v-if="isMenuVisible" />
</div>
</template>
<script>
import MainMenu from "@/components/MainMenu/MainMenu";
import BottomBar from "@/components/BottomBar/BottomBar";
export default {
name: 'Lens',
components: {
BottomBar,
MainMenu
},
computed:{
isMenuVisible: function(){ return this.$store.getters.isMenuVisible }
}
}
</script>
<style>
#lens-container {
position: absolute;
top: 0;
left: 70px;
right: 0;
height: 100%;
z-index: 100;
display: none;
}
</style>
+191
View File
@@ -0,0 +1,191 @@
@import 'custom.scss';
@import '~bootstrap/scss/bootstrap';
@import '~bootstrap-vue/src/index.scss';
@import 'fonts.scss';
html, body{
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background-color: $lens-main-bg;
color: $lens-text-color;
font-size: 14px; // font size that is used also on lens UI
-webkit-font-smoothing: antialiased;
}
body{
font-family: 'Roboto', sans-serif;
}
pre {
color: $lens-text-color-light;
}
#app{
width: 100%;
height: 100%;
& > .main-view{
width: 100%;
height: 100%;
& > .content{
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
overflow: hidden;
}
&.menu-visible > .content{
left: 70px;
bottom: 20px;
}
}
select {
appearance: none;
border: 1px solid $lens-pane-bg;
background-color: $lens-menu-bg;
color: $lens-text-color-light;
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAKxWlDQ1BEaXNwbGF5AABIiZWXd1BT+RbHv/emEhJaAgJSQm+CFOkivQSQ3m2EhBJKjCFg7yyuYEVFBCu4KDZcCyBrQSzYFnsvi7LY10VdbKi8P3iEfW/ezJs9M3fuZ75zft9zzm/uH+cCHKZQJssnNYACqUIeFxrAT0lN4zO6QQUTbLhCRSgqlPnHxEQCwND7b0EAH26BAIDr9kKZLB//LDTFmYUigIgBkCEuFBUAxCGAeCOSyRUAZTsAs+kKmQKgnAPAk6ekpgGUBwB42YPcC4CXkZKaBlCpAHjyhLhAgKoLMNlCoTwbYJsD4BeLshUAOwiAo1QskQJsBYBxohyhGGAfAjCqoGCqGGB3AbDO+JtP9n94Zig9hcJsJQ/OAgBgBkkKZfnCmf/wOv5/FOQXDdWwBMDOkYfFAdABiDt5UyOULM2Iih5iiRgY4pyisMQhFhUGpg2xWBgUoTybHxU5xFmSEIHSRyFIGOLMwuD4IZZPjVPWypIH+g+xUD5ctygvUannZAqU/rNyEpKHuFiSFDXEhXnxEcM5gUpdXhSn7D9TGhowXDdEOXtB4d/mlQiUZxU5CWHK2YXD/WdK/Yc9C1OUvYkzg4KHcxKV+TJFgLKWLD9GmZ+ZH6rUC4vjlWcV8oThfEWM8g5zheExQwx/xCMR8eAjBgFwhgfcEQsoMmcoACBwqmymXJKdo+D7y2T5mXyBVOQwiu/s6OQJpKSm8Qc/ifd3QAAgdJjDWr424GkBkDXDWsYroGU5oD4wrFndBNSigLYKUZG8eFCjAgANLKiDBz0YwQzWsIcz3OANPwQjHNFIQComQ4QcFECO6ZiDhShFOVZhHaqxBXXYib04gGYcxUmcxUVcwU3cRxd68Aq9+IB+giAYBIfgEnqEMWFB2BHOhAcxjggmIok4IpVIJ7IJKVFEzCEWE+VEBVFNbCMaiJ+JI8RJ4jxxlbhLPCZeEO+ILySFZJM80pC0JEeTHqQ/GUEmkJPIbHIaOYssIVeQVWQtuYdsIk+SF8mbZBf5iuyjgKJK0aGYUOwpHpRASjQljZJFkVPmUcoolZRayj5KK6WDcp3SRXlN+UylU7lUPtWe6k0NoyZSRdRp1HnUZdRq6k5qE/U09Tr1MbWX+p3GoRnQ7GheNAEthZZNm04rpVXS6mmHaWdoN2k9tA90Ol2HbkV3p4fRU+m59Nn0ZfRN9EZ6G/0qvZvex2Aw9Bh2DB9GNEPIUDBKGRsYexgnGNcYPYxPTFWmMdOZGcJMY0qZi5iVzF3M48xrzGfMfhUNFQsVL5VoFbHKTJWVKttVWlUuq/So9LM0WVYsH1YCK5e1kFXF2sc6w3rAeq+qqmqq6qkaqypRXaBapbpf9ZzqY9XPbC22LTuQPZFdxF7B3sFuY99lv+dwOJYcP04aR8FZwWngnOI84nxS46o5qAnUxGrz1WrUmtSuqb1RV1G3UPdXn6w+S71S/aD6ZfXXGioalhqBGkKNeRo1Gkc0bmv0aXI1nTSjNQs0l2nu0jyv+VyLoWWpFawl1irRqtM6pdXNpXDNuIFcEXcxdzv3DLeHR+dZ8QS8XF45by+vk9erraU9RjtJe4Z2jfYx7S4dio6ljkAnX2elzgGdWzpfRhiO8B+ROWLpiH0jro34qDtS1083U7dMt1H3pu4XPb5esF6e3mq9Zr2H+lR9W/1Y/en6m/XP6L8eyRvpPVI0smzkgZH3DEgDW4M4g9kGdQaXDPoMjQxDDWWGGwxPGb420jHyM8o1Wmt03OiFMdd4nLHEeK3xCeOXfG2+Pz+fX8U/ze81MTAJMyky2WbSadJvamWaaLrItNH0oRnLzMMsy2ytWbtZr7mx+XjzOea7ze9ZqFh4WORYrLfosPhoaWWZbLnEstnyuZWulcBqltVuqwfWHGtf62nWtdY3bOg2HjZ5NptsrtiStq62ObY1tpftSDs3O4ndJruro2ijPEdJR9WOum3Ptve3L7bfbf/YQcch0mGRQ7PDm9Hmo9NGrx7dMfq7o6tjvuN2x/tOWk7hToucWp3eOds6i5xrnG+4cFxCXOa7tLi8HWM3JnPM5jF3XLmu412XuLa7fnNzd5O77XN74W7unu6+0f22B88jxmOZxzlPmmeA53zPo56fvdy8FF4HvP70tvfO897l/Xys1djMsdvHdvuY+gh9tvl0jeOPSx+3dVyXr4mv0LfW94mfmZ/Yr97vmb+Nf67/Hv83AY4B8oDDAR8DvQLnBrYFUYJCg8qCOoO1ghODq4MfhZiGZIfsDukNdQ2dHdoWRguLCFsddltgKBAJGgS94e7hc8NPR7Aj4iOqI55E2kbKI1vHk+PDx68Z/yDKIkoa1RyNaEH0muiHMVYx02J+iaXHxsTWxD6Nc4qbE9cRz42fEr8r/kNCQMLKhPuJ1olFie1J6kkTkxqSPiYHJVckd6WMTpmbcjFVP1WS2pLGSEtKq0/rmxA8Yd2EnomuE0sn3ppkNWnGpPOT9SfnTz42RX2KcMrBdFp6cvqu9K/CaGGtsC9DkLExo1cUKFoveiX2E68Vv8j0yazIfJblk1WR9TzbJ3tN9osc35zKnNeSQEm15G1uWO6W3I950Xk78gbyk/MbC5gF6QVHpFrSPOnpqUZTZ0y9KrOTlcq6pnlNWzetVx4hry8kCicVtih4CpniUpF10Q9Fj4vHFdcUf5qeNP3gDM0Z0hmXZtrOXDrz2ayQWT/Nps4WzW6fYzJn4ZzHc/3nbptHzMuY1z7fbH7J/J4FoQt2LmQtzFv46yLHRRWL/lqcvLi1xLBkQUn3D6E/7C5VK5WX3l7ivWTLj9QfJT92LnVZumHp9zJx2YVyx/LK8q/LRMsuLHdaXrV8YEXWis6Vbis3r6Kvkq66tdp39c4KzYpZFd1rxq9pWstfW7b2r3VT1p2vHFO5ZT1rfdH6rqrIqpYN5htWbfhanVN9syagpnGjwcalGz9uEm+6ttlv874thlvKt3zZKtl6Z1votqZay9rKOnpdcd3T7UnbO37y+KmhXr++vP7bDumOrp1xO083uDc07DLYtXI3ubto94s9E/dc2Ru0t2Wf/b5tjTqN5fuxv2j/y5/Tf751IOJA+0GPg/sOWRzaeJh7uKyJaJrZ1Nuc09zVktpy9Uj4kfZW79bDvzj8suOoydGaY9rHVh5nHS85PnBi1om+Nlnb65PZJ7vbp7TfP5Vy6sbp2NOdZyLOnDsbcvZUh3/HiXM+546e9zp/5ILHheaLbhebLrleOvyr66+HO906my67X2654nml9erYq8ev+V47eT3o+tkbghsXb0bdvHor8dad2xNvd90R33l+N//u23vF9/rvL3hAe1D2UONh5SODR7W/2fzW2OXWdexx0ONLT+Kf3O8Wdb/6vfD3rz0lTzlPK58ZP2t47vz86IuQF1deTnjZ80r2qv916R+af2x8Y/3m0J9+f17qTenteSt/O/Bu2Xu99zv+GvNXe19M36MPBR/6P5Z90vu087PH544vyV+e9U//yvha9c3mW+v3iO8PBgoGBmRCuRAAQAFAZmUB73YAnFSAewVgTRjcqQEAxOB/ADC4g/xvHty7AQBuQB2AZD8gug3YCsBqAcBpA2L8gAQ/kC4uyuffUZjl4jzopdoM0CoHBt4nAwwb4NvtgYH+5oGBb/UA5R7Q9mFwlwcAqgSwrgOxcPN113tn8F/xL2AUEjlEqYDhAAAACXBIWXMAAAsTAAALEwEAmpwYAAAF9mlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDAgNzkuMTYwNDUxLCAyMDE3LzA1LzA2LTAxOjA4OjIxICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdEV2dD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlRXZlbnQjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOCAoTWFjaW50b3NoKSIgeG1wOkNyZWF0ZURhdGU9IjIwMTktMTEtMDVUMTU6NTI6MzMrMDI6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTktMTEtMDVUMTU6NTI6MzMrMDI6MDAiIHhtcDpNb2RpZnlEYXRlPSIyMDE5LTExLTA1VDE1OjUyOjMzKzAyOjAwIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjUxMTI1YTgyLWY2YmUtNDY1ZC05NDAyLTk0NWNhZGFjYTkzZSIgeG1wTU06RG9jdW1lbnRJRD0iYWRvYmU6ZG9jaWQ6cGhvdG9zaG9wOjJiNjlhZDExLTI0M2MtMzk0Ny05NDM3LWNiZjkzNTM3ODFiYyIgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSLine truncated
background-repeat: no-repeat, repeat;
background-position: right .7em top 50%, 0 0;
background-size: .65em auto, 100%;
}
}
.draggable-top{
-webkit-app-region: drag;
left: 0;
top: 0;
position: absolute;
height: 20px;
width: 100%;
}
h1, h2, h3, h4, h5, h6{
color: #fff;
font-size: 20.8px;
font-weight: 300;
}
.table{
color: $lens-text-color-light;
th{
border-top: 1px solid #353a3e;
font-weight: 300;
color: $lens-text-color;
}
td{
border-top: 1px solid #353a3e;
}
tr:first-child{
th, td{
border-top: none;
}
}
}
.card{
background: $lens-pane-bg;
}
.help{
border-left: 1px solid #353a3e;
padding-top: 20px;
&:first-child{
padding-top: 0;
}
h3{
padding: 0.75rem 0 0.75rem 0;
}
}
.tooltip-inner {
font-size: 12px;
}
.editor {
height: 400px;
font-size: 12px;
pre {
background-color: #1E1E1E;
}
}
.aligner-center-center{
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.wrapper{
width:100%;
height:100%;
display:flex;
justify-content:center;
align-items:center;
.error{
text-align: center;
i {
color: #dc3545;
font-size: 100px;
}
}
}
.btn {
line-height: 1.3;
}
// Force BS modals to use main app font etc.
.modal-open{
@extend #app
}
.popover {
background-color: $lens-menu-hl;
select {
appearance: none;
border: 1px solid $lens-pane-bg;
background-color: $lens-menu-bg;
color: $lens-text-color-light;
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAKxWlDQ1BEaXNwbGF5AABIiZWXd1BT+RbHv/emEhJaAgJSQm+CFOkivQSQ3m2EhBJKjCFg7yyuYEVFBCu4KDZcCyBrQSzYFnsvi7LY10VdbKi8P3iEfW/ezJs9M3fuZ75zft9zzm/uH+cCHKZQJssnNYACqUIeFxrAT0lN4zO6QQUTbLhCRSgqlPnHxEQCwND7b0EAH26BAIDr9kKZLB//LDTFmYUigIgBkCEuFBUAxCGAeCOSyRUAZTsAs+kKmQKgnAPAk6ekpgGUBwB42YPcC4CXkZKaBlCpAHjyhLhAgKoLMNlCoTwbYJsD4BeLshUAOwiAo1QskQJsBYBxohyhGGAfAjCqoGCqGGB3AbDO+JtP9n94Zig9hcJsJQ/OAgBgBkkKZfnCmf/wOv5/FOQXDdWwBMDOkYfFAdABiDt5UyOULM2Iih5iiRgY4pyisMQhFhUGpg2xWBgUoTybHxU5xFmSEIHSRyFIGOLMwuD4IZZPjVPWypIH+g+xUD5ctygvUannZAqU/rNyEpKHuFiSFDXEhXnxEcM5gUpdXhSn7D9TGhowXDdEOXtB4d/mlQiUZxU5CWHK2YXD/WdK/Yc9C1OUvYkzg4KHcxKV+TJFgLKWLD9GmZ+ZH6rUC4vjlWcV8oThfEWM8g5zheExQwx/xCMR8eAjBgFwhgfcEQsoMmcoACBwqmymXJKdo+D7y2T5mXyBVOQwiu/s6OQJpKSm8Qc/ifd3QAAgdJjDWr424GkBkDXDWsYroGU5oD4wrFndBNSigLYKUZG8eFCjAgANLKiDBz0YwQzWsIcz3OANPwQjHNFIQComQ4QcFECO6ZiDhShFOVZhHaqxBXXYib04gGYcxUmcxUVcwU3cRxd68Aq9+IB+giAYBIfgEnqEMWFB2BHOhAcxjggmIok4IpVIJ7IJKVFEzCEWE+VEBVFNbCMaiJ+JI8RJ4jxxlbhLPCZeEO+ILySFZJM80pC0JEeTHqQ/GUEmkJPIbHIaOYssIVeQVWQtuYdsIk+SF8mbZBf5iuyjgKJK0aGYUOwpHpRASjQljZJFkVPmUcoolZRayj5KK6WDcp3SRXlN+UylU7lUPtWe6k0NoyZSRdRp1HnUZdRq6k5qE/U09Tr1MbWX+p3GoRnQ7GheNAEthZZNm04rpVXS6mmHaWdoN2k9tA90Ol2HbkV3p4fRU+m59Nn0ZfRN9EZ6G/0qvZvex2Aw9Bh2DB9GNEPIUDBKGRsYexgnGNcYPYxPTFWmMdOZGcJMY0qZi5iVzF3M48xrzGfMfhUNFQsVL5VoFbHKTJWVKttVWlUuq/So9LM0WVYsH1YCK5e1kFXF2sc6w3rAeq+qqmqq6qkaqypRXaBapbpf9ZzqY9XPbC22LTuQPZFdxF7B3sFuY99lv+dwOJYcP04aR8FZwWngnOI84nxS46o5qAnUxGrz1WrUmtSuqb1RV1G3UPdXn6w+S71S/aD6ZfXXGioalhqBGkKNeRo1Gkc0bmv0aXI1nTSjNQs0l2nu0jyv+VyLoWWpFawl1irRqtM6pdXNpXDNuIFcEXcxdzv3DLeHR+dZ8QS8XF45by+vk9erraU9RjtJe4Z2jfYx7S4dio6ljkAnX2elzgGdWzpfRhiO8B+ROWLpiH0jro34qDtS1083U7dMt1H3pu4XPb5esF6e3mq9Zr2H+lR9W/1Y/en6m/XP6L8eyRvpPVI0smzkgZH3DEgDW4M4g9kGdQaXDPoMjQxDDWWGGwxPGb420jHyM8o1Wmt03OiFMdd4nLHEeK3xCeOXfG2+Pz+fX8U/ze81MTAJMyky2WbSadJvamWaaLrItNH0oRnLzMMsy2ytWbtZr7mx+XjzOea7ze9ZqFh4WORYrLfosPhoaWWZbLnEstnyuZWulcBqltVuqwfWHGtf62nWtdY3bOg2HjZ5NptsrtiStq62ObY1tpftSDs3O4ndJruro2ijPEdJR9WOum3Ptve3L7bfbf/YQcch0mGRQ7PDm9Hmo9NGrx7dMfq7o6tjvuN2x/tOWk7hToucWp3eOds6i5xrnG+4cFxCXOa7tLi8HWM3JnPM5jF3XLmu412XuLa7fnNzd5O77XN74W7unu6+0f22B88jxmOZxzlPmmeA53zPo56fvdy8FF4HvP70tvfO897l/Xys1djMsdvHdvuY+gh9tvl0jeOPSx+3dVyXr4mv0LfW94mfmZ/Yr97vmb+Nf67/Hv83AY4B8oDDAR8DvQLnBrYFUYJCg8qCOoO1ghODq4MfhZiGZIfsDukNdQ2dHdoWRguLCFsddltgKBAJGgS94e7hc8NPR7Aj4iOqI55E2kbKI1vHk+PDx68Z/yDKIkoa1RyNaEH0muiHMVYx02J+iaXHxsTWxD6Nc4qbE9cRz42fEr8r/kNCQMLKhPuJ1olFie1J6kkTkxqSPiYHJVckd6WMTpmbcjFVP1WS2pLGSEtKq0/rmxA8Yd2EnomuE0sn3ppkNWnGpPOT9SfnTz42RX2KcMrBdFp6cvqu9K/CaGGtsC9DkLExo1cUKFoveiX2E68Vv8j0yazIfJblk1WR9TzbJ3tN9osc35zKnNeSQEm15G1uWO6W3I950Xk78gbyk/MbC5gF6QVHpFrSPOnpqUZTZ0y9KrOTlcq6pnlNWzetVx4hry8kCicVtih4CpniUpF10Q9Fj4vHFdcUf5qeNP3gDM0Z0hmXZtrOXDrz2ayQWT/Nps4WzW6fYzJn4ZzHc/3nbptHzMuY1z7fbH7J/J4FoQt2LmQtzFv46yLHRRWL/lqcvLi1xLBkQUn3D6E/7C5VK5WX3l7ivWTLj9QfJT92LnVZumHp9zJx2YVyx/LK8q/LRMsuLHdaXrV8YEXWis6Vbis3r6Kvkq66tdp39c4KzYpZFd1rxq9pWstfW7b2r3VT1p2vHFO5ZT1rfdH6rqrIqpYN5htWbfhanVN9syagpnGjwcalGz9uEm+6ttlv874thlvKt3zZKtl6Z1votqZay9rKOnpdcd3T7UnbO37y+KmhXr++vP7bDumOrp1xO083uDc07DLYtXI3ubto94s9E/dc2Ru0t2Wf/b5tjTqN5fuxv2j/y5/Tf751IOJA+0GPg/sOWRzaeJh7uKyJaJrZ1Nuc09zVktpy9Uj4kfZW79bDvzj8suOoydGaY9rHVh5nHS85PnBi1om+Nlnb65PZJ7vbp7TfP5Vy6sbp2NOdZyLOnDsbcvZUh3/HiXM+546e9zp/5ILHheaLbhebLrleOvyr66+HO906my67X2654nml9erYq8ev+V47eT3o+tkbghsXb0bdvHor8dad2xNvd90R33l+N//u23vF9/rvL3hAe1D2UONh5SODR7W/2fzW2OXWdexx0ONLT+Kf3O8Wdb/6vfD3rz0lTzlPK58ZP2t47vz86IuQF1deTnjZ80r2qv916R+af2x8Y/3m0J9+f17qTenteSt/O/Bu2Xu99zv+GvNXe19M36MPBR/6P5Z90vu087PH544vyV+e9U//yvha9c3mW+v3iO8PBgoGBmRCuRAAQAFAZmUB73YAnFSAewVgTRjcqQEAxOB/ADC4g/xvHty7AQBuQB2AZD8gug3YCsBqAcBpA2L8gAQ/kC4uyuffUZjl4jzopdoM0CoHBt4nAwwb4NvtgYH+5oGBb/UA5R7Q9mFwlwcAqgSwrgOxcPN113tn8F/xL2AUEjlEqYDhAAAACXBIWXMAAAsTAAALEwEAmpwYAAAF9mlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDAgNzkuMTYwNDUxLCAyMDE3LzA1LzA2LTAxOjA4OjIxICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdEV2dD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlRXZlbnQjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOCAoTWFjaW50b3NoKSIgeG1wOkNyZWF0ZURhdGU9IjIwMTktMTEtMDVUMTU6NTI6MzMrMDI6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTktMTEtMDVUMTU6NTI6MzMrMDI6MDAiIHhtcDpNb2RpZnlEYXRlPSIyMDE5LTExLTA1VDE1OjUyOjMzKzAyOjAwIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjUxMTI1YTgyLWY2YmUtNDY1ZC05NDAyLTk0NWNhZGFjYTkzZSIgeG1wTU06RG9jdW1lbnRJRD0iYWRvYmU6ZG9jaWQ6cGhvdG9zaG9wOjJiNjlhZDExLTI0M2MtMzk0Ny05NDM3LWNiZjkzNTM3ODFiYyIgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSLine truncated
background-repeat: no-repeat, repeat;
background-position: right .7em top 50%, 0 0;
background-size: .65em auto, 100%;
}
.arrow:after {
border-top-color: $lens-menu-hl;
}
}
.popover-body {
color: $lens-text-color;
li.list-group-item {
background-color: inherit;
padding: 0.5rem 0.75rem;
a {
color: var(--lens-text-color-light);
}
}
}
.popover-header{
color: $lens-text-color;
background-color: $lens-main-bg;
border-bottom: 0px;
i.material-icons {
position: relative;
top: 3px;
font-size: 16px;
}
}
+72
View File
@@ -0,0 +1,72 @@
// from Kontena Lens UI
$lens-main-bg: #1e2124 !default; // dark bg
$lens-pane-bg: #262b2f !default; // all panels main bg
$lens-dock-bg: #2E3136 !default; // terminal and top menu bar
$lens-menu-bg: #36393E !default; // sidemenu on left
$lens-menu-hl: #414448 !default; // sidemenu on left, top left corner
$lens-text-color: #87909c !default;
$lens-text-color-light: #a0a0a0 !default;
$kontena-blue: #3d90ce !default;
// export as css variables
* {
--lens-main-bg: #{$lens-main-bg}; // dark bg
--lens-pane-bg: #{$lens-pane-bg}; // all panels main bg
--lens-dock-bg: #{$lens-dock-bg}; // terminal and top menu bar
--lens-menu-bg: #{$lens-menu-bg}; // sidemenu on left
--lens-menu-hl: #{$lens-menu-hl}; // sidemenu on left, top left corner
--lens-text-color: #{$lens-text-color};
--lens-text-color-light: #{$lens-text-color-light};
--kontena-blue: #{$kontena-blue};
}
// Base grayscale colors definitions
$white: #fff !default;
$gray-100: #f8f9fa !default;
$gray-200: #e9ecef !default;
$gray-300: #dee2e6 !default;
$gray-400: #ced4da !default;
$gray-500: #adb5bd !default;
$gray-600: #6c757d !default;
$gray-700: #495057 !default;
$gray-800: #343a40 !default;
$gray-900: #1e2124 !default;
$black: #000 !default;
// Base colors definitions
$blue: #3d90ce !default;
$indigo: #6610f2 !default;
$purple: #6f42c1 !default;
$pink: #e83e8c !default;
$red: #CE3933 !default;
$orange: #fd7e14 !default;
$yellow: #ffc107 !default;
$green: #4caf50 !default;
$teal: #20c997 !default;
$cyan: #6ca5b7 !default;
// Theme color default definitions
$primary: $kontena-blue !default;
$secondary: $gray-600 !default;
$success: $green !default;
$info: $cyan !default;
$warning: $yellow !default;
$danger: $red !default;
$light: $gray-100 !default;
$dark: $gray-800 !default;
// This table defines the theme colors (variant names)
$theme-colors: () !default;
$theme-colors: map-merge(
(
'primary': $primary,
'secondary': $secondary,
'success': $success,
'info': $info,
'warning': $warning,
'danger': $danger,
'light': $light,
'dark': $dark
),
$theme-colors
);
+2
View File
@@ -0,0 +1,2 @@
@import "~typeface-roboto/index.css";
@import "~material-design-icons/iconfont/material-icons.css";
+182
View File
@@ -0,0 +1,182 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 1303.8 737.4" style="enable-background:new 0 0 1303.8 737.4;" xml:space="preserve">
<style type="text/css">
.st0{clip-path:url(#SVGID_2_);}
.st1{fill:#393C40;}
.st2{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:9.54,9.54;}
.st3{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:10.35,10.35;}
.st4{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:9.06,9.06;}
.st5{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:12.67,12.67;}
.st6{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:12.81,12.81;}
.st7{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:10.4,10.4;}
.st8{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:9.25,9.25;}
.st9{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:9.55,9.55;}
.st10{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:9.66,9.66;}
.st11{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:10.37,10.37;}
.st12{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:10,10;}
.st13{fill:none;stroke:#393C40;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:11.37,11.37;}
.st14{clip-path:url(#SVGID_4_);}
.st15{fill:#2E3136;}
.st16{clip-path:url(#SVGID_6_);}
.st17{clip-path:url(#SVGID_8_);}
</style>
<title>Artboard 1</title>
<g>
<defs>
<rect id="SVGID_1_" width="1303.8" height="737.4"/>
</defs>
<clipPath id="SVGID_2_">
<use xlink:href="#SVGID_1_" style="overflow:visible;"/>
</clipPath>
<g class="st0">
<path class="st1" d="M715.5,222.6c0.2-6.2-4-12.2-9.6-15V190c-2.1,1.9-4.9,3-7.8,3c-1.8,0-3.6-0.5-5.2-1.4v15.9
c-5.6,2.6-9.3,8.2-9.6,14.4c-0.1,2.7,0.6,5.5,1.8,7.9c-0.1-0.8-0.2-1.7-0.2-2.5c0.2-7.8,7.1-14.5,14.7-14.2s14.4,7.4,14.2,14.9
c0,0.4,0,0.8-0.1,1.2C714.8,227.2,715.4,225,715.5,222.6"/>
</g>
</g>
<line class="st2" x1="870.3" y1="376.4" x2="870.3" y2="519.5"/>
<line class="st3" x1="936.8" y1="407.1" x2="936.8" y2="562.4"/>
<line class="st4" x1="1034.7" y1="581.8" x2="1034.7" y2="627.1"/>
<line class="st5" x1="967.1" y1="547" x2="967.1" y2="585"/>
<line class="st6" x1="1104.2" y1="542.4" x2="1104.2" y2="580.8"/>
<line class="st7" x1="1036.7" y1="279.6" x2="1036.7" y2="435.6"/>
<line class="st8" x1="1106.2" y1="240.8" x2="1106.2" y2="361"/>
<line class="st9" x1="1204" y1="436.5" x2="1204" y2="522.4"/>
<line class="st10" x1="1134.5" y1="475.5" x2="1134.5" y2="562.4"/>
<line class="st11" x1="1064.9" y1="436.5" x2="1064.9" y2="467.6"/>
<line class="st12" x1="1006.4" y1="370.6" x2="1006.4" y2="460.6"/>
<line class="st13" x1="965.1" y1="240.8" x2="965.1" y2="297.6"/>
<g>
<defs>
<rect id="SVGID_3_" width="1303.8" height="737.4"/>
</defs>
<clipPath id="SVGID_4_">
<use xlink:href="#SVGID_3_" style="overflow:visible;"/>
</clipPath>
<g class="st14">
<path class="st15" d="M773.7,590.9c-8.2-4.8-8.2-12.5,0-17.3L823,545c8.2-4.8,21.7-4.8,30,0l49.4,28.6c8.2,4.8,8.2,12.5,0,17.3
L853,619.5c-8.2,4.8-21.7,4.8-30,0L773.7,590.9z"/>
<path class="st15" d="M921.8,676.7c8.2,4.8,21.7,4.8,30,0l49.4-28.6c8.2-4.8,8.2-12.5,0-17.3l-49.4-28.6c-8.2-4.8-21.7-4.8-30,0
l-49.4,28.6c-8.2,4.8-8.2,12.5,0,17.3L921.8,676.7z"/>
<path class="st15" d="M1020.7,733.8c8.2,4.8,21.7,4.8,30,0l49.3-28.6c8.2-4.8,8.2-12.5,0-17.3l-49.4-28.6c-8.2-4.8-21.7-4.8-30,0
l-49.4,28.6c-8.2,4.8-8.2,12.5,0,17.3L1020.7,733.8z"/>
<path class="st15" d="M1119.5,676.7c8.2,4.8,21.7,4.8,30,0l49.4-28.6c8.2-4.8,8.2-12.5,0-17.3l-49.4-28.6c-8.2-4.8-21.7-4.8-30,0
l-49.4,28.6c-8.2,4.8-8.2,12.5,0,17.3L1119.5,676.7z"/>
<path class="st15" d="M1248.3,545.1c-8.2-4.8-21.7-4.8-30,0l-49.4,28.6c-8.2,4.8-8.2,12.5,0,17.3l49.4,28.6c8.2,4.8,21.7,4.8,30,0
l49.4-28.5c8.2-4.8,8.2-12.5,0-17.3L1248.3,545.1z"/>
<path class="st15" d="M942.8,369.3c-3.5,2.3-5.7,6-5.9,10.2V391c0,3.7-2.7,5.2-6,3.4l-51.6-29.2c-3.5-2.2-5.7-6-6-10.1v-22.7
l1.1-0.6c0.6-0.3,3.8,0.9,7,2.7l48.6,27.3c3.7,1.8,8.1,1.8,11.9,0l49.1-28.3c3.3-1.9,6.7-3.1,7.7-2.8c1.1,0.5,1.8,1.6,1.8,2.8
c0,1.2-2.6,3.7-5.9,5.6L942.8,369.3z M942.8,292.3c-3.7-1.9-8.1-1.9-11.8,0l-58.7,33.9c-3.5,2.2-5.7,6-5.9,10.1v22.9
c0.2,4.1,2.5,7.9,6,10.1l58.7,33.2c3.7,1.9,8.1,1.9,11.9,0l58.7-33.2c3.5-2.2,5.7-6,6-10.1v-22.8c-0.2-4.1-2.4-7.9-5.9-10.1
L942.8,292.3z"/>
<path class="st15" d="M1140.4,437.4c-3.5,2.3-5.7,6-5.9,10.2v11.6c0,3.7-2.7,5.2-5.9,3.4l-51.6-29.2c-3.5-2.2-5.7-6-5.9-10.1
v-22.8l1.1-0.6c0.6-0.3,3.8,0.9,7,2.7l48.5,27.3c3.7,1.9,8.1,1.9,11.9,0l49.2-28.3c3.3-1.9,6.7-3.1,7.7-2.8
c1.1,0.5,1.8,1.6,1.8,2.8c0,1.2-2.6,3.7-5.9,5.6L1140.4,437.4z M1140.4,360.4c-3.7-1.9-8.1-1.9-11.8,0l-58.7,33.9
c-3.5,2.2-5.7,6-5.9,10.1v22.9c0.2,4.1,2.5,7.9,5.9,10.1l58.7,33.2c3.7,1.9,8.1,1.9,11.9,0l58.7-33.2c3.5-2.2,5.7-6,5.9-10.1
v-22.9c-0.2-4.1-2.4-7.9-5.9-10.1L1140.4,360.4z"/>
<path class="st15" d="M1041.6,241c-3.5,2.3-5.7,6-5.9,10.2v11.6c0,3.7-2.7,5.2-5.9,3.4L978.1,237c-3.5-2.2-5.7-6-6-10.1v-22.8
l1.1-0.6c0.6-0.3,3.8,0.9,7,2.7l48.5,27.3c3.7,1.9,8.1,1.9,11.9,0l49.2-28.3c3.3-1.9,6.7-3.1,7.7-2.8c1.1,0.5,1.8,1.6,1.8,2.8
c0,1.2-2.6,3.7-5.9,5.6L1041.6,241z M1041.6,164c-3.7-1.9-8.1-1.9-11.8,0L971,197.9c-3.5,2.2-5.7,6-5.9,10.1v22.8
c0.2,4.1,2.5,7.9,6,10.1l58.7,33.2c3.7,1.9,8.1,1.9,11.9,0l58.7-33.2c3.5-2.2,5.7-6,5.9-10.1v-22.8c-0.2-4.1-2.4-7.9-5.9-10.1
L1041.6,164z"/>
<path class="st15" d="M1041.6,538.7c-3.5,2.3-5.7,6-5.9,10.2v11.6c0,3.7-2.7,5.2-5.9,3.4l-51.6-29.2c-3.5-2.2-5.7-6-6-10.1v-22.8
l1.1-0.6c0.6-0.3,3.8,0.9,7,2.7l48.5,27.3c3.7,1.8,8.1,1.8,11.9,0l49.2-28.3c3.3-1.9,6.7-3.1,7.7-2.8c1.1,0.5,1.8,1.6,1.8,2.8
c0,1.2-2.6,3.7-5.9,5.6L1041.6,538.7z M1041.6,461.7c-3.7-1.9-8.1-1.9-11.8,0L971,495.6c-3.5,2.2-5.7,6-5.9,10.1v22.9
c0.2,4.1,2.5,7.9,6,10.1l58.7,33.2c3.7,1.9,8.1,1.9,11.9,0l58.7-33.2c3.5-2.2,5.7-6,5.9-10.1v-22.9c-0.2-4.1-2.4-7.9-5.9-10.1
L1041.6,461.7z"/>
<path class="st1" d="M582.5,50.3l-0.1-0.1L582.5,50.3 M396.4,166.1c-6.8,6.8-6.1,10.1,2.8,13.7c10.5,4.3,17.5,12,21.7,22.5
c2.9,7.2,6.9,8.1,12.4,2.6c16-15.9,26.1-26,42.2-41.8c3.9-4,8.2-9.3,8.9-16c1.4-10.3,5-17,9-21c19.1-19,27.1-27.7,40.5-41.3
c1.6-1.7,2.5-4.1,2.4-6.5c-1.6-10.5,1.7-18.2,10.5-23.3c8.2-4.7,16.9-4,24.6,2.1c0.3,0.2,1.7,1.7,1.8,1.9c0,0,0.2,0.1,4.4-4.1
l4.8-4.8c-3.1-3.1-22.3-22.2-24.7-24.4c-4.9-4.7-3.9-4.7-4.9-3.7L527,47.2c0,0-3.8,3.8-9.6-1.9C517.8,45,436.3,126.2,396.4,166.1"
/>
<path class="st1" d="M180.6,384c0,0.3,0,0.6,0,0.9C180.6,384.5,180.6,384.2,180.6,384 M362.3,256.2c-10.5-4.3-17.5-12-21.7-22.5
c-2.9-7.2-6.9-8.1-12.4-2.6c-16,15.9-31.8,32-47.9,47.8c-3.2,3.2-4.9,6.3-4,10.7c1.8,8.8-1.6,15.1-8.1,21.2
c-12,11.2-22.9,22.5-34.4,34.3c11.9,4,22.6,15.8,27,29l104.2-104.2C372,263,371.2,259.8,362.3,256.2"/>
<path class="st1" d="M616.5,60.2c-0.2,0.2-0.3,0.4-0.3,0.6C616.2,60.6,616.3,60.4,616.5,60.2 M616.2,60.9c0,0,0,0.1,0,0.1
C616.1,61,616.1,60.9,616.2,60.9 M616.5,61.6c0.1,0.1,0.1,0.1,0.2,0.2C616.6,61.7,616.5,61.7,616.5,61.6 M626.9,61.2
c-0.1-3.3-2.6-6-5.9-6.2c-3.3,0-6,2.6-6.1,5.9c-0.1,3.3,2.5,6,5.8,6.2C624,67,626.7,64.5,626.9,61.2"/>
<path class="st1" d="M575.9,72.3c-0.1-10.5-8.7-18.9-19.2-18.8s-18.9,8.7-18.8,19.2c0,10.5,9,19.4,19.2,19.2
C567.8,91.7,576.1,82.9,575.9,72.3"/>
<path class="st1" d="M188.9,482h-53c-12,0-12-12-12-12v-71c0,82,65,82.8,65,82.8C200.9,482,188.9,482,188.9,482 M223.2,483v-46
c0-6-6-6.3-6-6.3c-11.7-0.9-22.6-6.1-30.7-14.5c-10-10.7-11.2-24.1-10.8-32.4c0.3-6-5.7-6-5.7-6h-47c-6,0-6,6-6,6V483c0,6,6,6,6,6
h94.2C223.2,489,223.2,483,223.2,483"/>
<path class="st1" d="M2.2,363.7c0,0-0.6-1.2-0.8-1.1l-1,0.4c0,0-0.3,0.1-0.3-5.9v-24.4c0,0,0-6,2.4-6s11.8-0.4,16.2-0.4
c11,0,13.1,2.1,13.1,13.2c0,12.6,0,25.2,0,32.2c0,0,0,6,3.8,5.7s4.8,3.7,4.8,9.5c-0.2,27.7-0.2,55.4,0,83.1c0,6.2,0.2,12-4.2,11.8
c0,0-4.4-0.1-4.4,5.9c0,7.9,0.1,21.2,0,34.5c0,6.5-2.9,10.1-8.9,10.4s-16.8,0.4-19.9,0.4c0,0-3.1,0-3.1-6v-24.6c0,0,0-6,0.3-5.9
l1.2,0.3c0,0,0.3,0.1,0.8-1.1s2.5-3.7,2.5-6.1c0.2-40,0.2-79.9,0-119.9C5,367.5,2.9,365,2.2,363.7"/>
<path class="st1" d="M114.9,469.9l-1.5,1.2c0,0-0.3,0.2-0.8-0.5s-2.3-2.1-2.3-3.5c-0.2-23.2-0.2-46.4,0-69.6c0-1.3,2-2.6,2.5-3.2
c0,0,0.5-0.6,0.8-0.5l1.3,0.9 M44.9,432.6c0-15.2,0.1-30.5,0-45.8c0-5.4,2.1-7.9,7.6-7.8c10.5,0.2,37.1,0.1,47.6,0
c5.4-0.1,7.9,2.2,7.9,7.6c-0.1,30.5-0.1,61,0,91.5c0,5.3-2.2,8-7.7,7.9c-10.3-0.1-36.7-0.1-47,0c-5.8,0.1-8.6-2.3-8.5-8.4
C45,462.6,44.8,447.6,44.9,432.6"/>
<path class="st1" d="M218.4,427.1c-24.7-0.2-40.3-21.7-39.5-41.9c1-24.1,19.7-40.8,43.9-39.6c21.8,1.1,38.3,20.9,37,44.4
C258.6,410.9,239.4,428.2,218.4,427.1"/>
<path class="st15" d="M692.1,363.5c4.6,2.3,10,2.2,14.5-0.1l60.3-34.7c4-2.3,8.2-3.8,9.4-3.4c1.1,0.5,1.9,1.5,2.2,2.7
c0,0,0,0.7,0,1.4s-3.2,3.8-7.2,6.1l-63.5,37.1c-4,2.3-7.2,7.9-7.2,12.5v14.2c0,4.5-3.3,6.4-7.3,4.1L630,367.7
c-4-2.3-7.3-7.8-7.3-12.4v-27.9c0,0,0,0,0.3-0.2l1-0.6c0.7-0.4,4.6,1.1,8.6,3.3L692.1,363.5z M693.3,278.3l-72,41.5
c-4.1,2.7-6.7,7.1-7.2,11.9l1.7,0.2c1.2,0.2,2,1.3,1.9,2.5v8c0,0,0,1.1-3.7,2.8v14.9c0,4.5,3.3,10.1,7.3,12.4l71.9,40.7
c4.6,2.3,10,2.3,14.6,0l71.9-40.7c4-2.3,7.3-7.8,7.3-12.4v-12c-0.3-0.1-0.6-0.1-0.8-0.2c-8.1-2.7-8.1-4.6-8.1-4.6v-8
c-0.1-1.2,0.7-2.3,1.9-2.5l7-1c-0.4-4.9-3.1-9.4-7.2-12.1l-72-41.5C703.2,276,697.9,276,693.3,278.3"/>
<path class="st1" d="M487.8,147L487.8,147l35.2-36.8l-5.1-4.9l-23,23.7C494.9,129,488.9,135,487.8,147"/>
<path class="st1" d="M519.9,43.1L519.9,43.1L563.6,0l5.3,5.2l-14,14.4l-1.3-1.3l-27.2,26.1C526.4,44.5,523.8,47.1,519.9,43.1"/>
<path class="st1" d="M631.5,61.3l-7.1,7.1c0,0-4.2,4.2,0,8.5l62.5,62.6c0,0,4.2,4.2,8.5,0l2.8-2.8c0,0,4.2-4.2,0-8.5L631.5,61.3z"
/>
<path class="st1" d="M709.3,166.1L594.9,51.7c0,0-4.2-4.2-8.5,0l-2.8,2.8c0,0-4.2,4.2,0,8.5l24.1,24.1l-11.6,11.6
c0,0-4.2,4.2,0,8.5l86,86c0,0,4.2,4.2,8.5,0l1.9-1.9c-3.5-2.2-5.6-6-5.5-10.1c0.2-6.2,5.7-11.5,11.6-11.2c3.9,0.3,7.4,2.4,9.5,5.7
l1.2-1.2C709.3,174.5,713.5,170.3,709.3,166.1 M690.2,182.8c-0.1-0.1-0.2-0.2-0.3-0.3L690.2,182.8 M690.4,183l-0.1-0.1L690.4,183
M689.4,181c0.1-0.4,0.3-0.8,0.7-1.1C689.7,180.2,689.4,180.6,689.4,181"/>
<path class="st1" d="M708.4,138.2c0,0-4.2-4.2-8.5,0L697,141c0,0-4.2,4.2,0,8.5l14,13.9L708.4,138.2z"/>
<path class="st1" d="M693,228.7c0.1-0.3,0.2-0.6,0.5-0.8C693.2,228.1,693.1,228.4,693,228.7 M693.7,230c-0.1-0.1-0.2-0.2-0.3-0.3
C693.5,229.8,693.6,229.9,693.7,230 M707.3,229.2c-0.3,4.3-3.9,7.7-8.2,7.8c-4.4-0.2-7.8-3.8-7.7-8.2c0.2-4.4,3.7-7.8,8.1-7.8
C703.8,221.3,707.2,224.9,707.3,229.2 M690.5,227.4c-0.3,0.3-0.6,0.7-0.7,1.1C689.9,228,690.2,227.6,690.5,227.4 M724.9,239v-3
c0,0,0-5-5-5h-8.8c0.1-0.5,0.2-1.1,0.2-1.6c0.2-6.2-5.4-12.1-11.7-12.4s-12,5.3-12.2,11.7c0,0.8,0,1.5,0.2,2.3h-8.7c0,0-5,0-5,5v3
c0,0,0,5,5,5h41C719.9,244,724.9,244,724.9,239"/>
<path class="st1" d="M707.7,181.8c-0.1-5-4-9.1-9-9.5c-4.8-0.2-9.2,4.1-9.4,9c-0.1,5.1,3.8,9.3,8.9,9.4
C703.2,190.6,707.4,186.7,707.7,181.8"/>
</g>
</g>
<polygon class="st1" points="570.8,7.1 570.8,7.1 568.9,9 615.4,55.5 617.3,53.6 "/>
<g>
<defs>
<rect id="SVGID_5_" width="1303.8" height="737.4"/>
</defs>
<clipPath id="SVGID_6_">
<use xlink:href="#SVGID_5_" style="overflow:visible;"/>
</clipPath>
<g class="st16">
<path class="st1" d="M586.1,44.3l-25.1-25.1c-0.4-0.4-1-0.4-1.4,0c0,0-0.1,0.1-0.1,0.1l-1.8,1.8c-0.1,0.1-0.1,0.2-0.2,0.3
L583.9,48l0.1-0.1l0,0c0.1-0.1,0.2-0.1,0.3-0.2l1.7-1.9C586,45.8,586.8,45,586.1,44.3"/>
</g>
</g>
<polygon class="st1" points="540.7,88.9 540.7,88.9 538.8,87 521,105.1 522.9,107 "/>
<g>
<defs>
<rect id="SVGID_7_" width="1303.8" height="737.4"/>
</defs>
<clipPath id="SVGID_8_">
<use xlink:href="#SVGID_7_" style="overflow:visible;"/>
</clipPath>
<g class="st17">
<path class="st1" d="M1123.8,193.5c0.3,0.3,0.1,0.5-0.2,0.7c-2.5,1.6-5,3.1-7.4,4.8c-2,1.3-3.6,3-4.7,5.1
c-0.8,1.6-0.7,3.6,0.3,5.2c1.3,2,3,3.7,5.1,5c2.2,1.5,4.5,2.9,6.7,4.3c0.3,0.2,0.5,0.3,0.2,0.6c-1.7-0.9-3.4-1.9-5.1-2.7
c-2.1-1.1-4.3-2-6.6-2.6c-1.8-0.6-3.7-0.5-5.4,0.2c-1.3,0.7-2.4,2-2.8,3.4c-0.7,1.8-1.1,3.6-1.2,5.5c-0.3,2.8-0.4,5.7-0.3,8.5
c0,0.1,0,0.2,0,0.2h-0.6c0-0.2,0-0.3,0-0.5c-0.1-2.3-0.1-4.6-0.3-6.9c-0.1-2.5-0.6-4.9-1.5-7.2c-0.6-1.9-2.2-3.3-4.2-3.6
c-1.4-0.2-2.8-0.1-4.1,0.4c-1.8,0.6-3.5,1.2-5.2,2c-2.1,1-4.2,2.1-6.3,3.2l-0.2-0.3c0.1-0.1,0.3-0.2,0.4-0.3
c2.5-1.6,5-3.1,7.4-4.9c1.9-1.3,3.5-3,4.6-5c0.9-1.7,0.7-3.7-0.3-5.2c-1.3-2-3-3.7-5-4.9c-2.2-1.5-4.5-2.9-6.7-4.3
c-0.3-0.2-0.5-0.3-0.2-0.6c1.8,1,3.6,2,5.4,2.9c2.2,1.1,4.4,2,6.8,2.6c1.3,0.3,2.6,0.4,3.9,0.1c1.7-0.5,3-1.7,3.6-3.3
c0.8-1.9,1.3-4,1.4-6.1c0.2-2.3,0.2-4.6,0.4-6.9c0-0.5,0.1-1,0.1-1.5c0-0.1,0.2-0.2,0.3-0.3l0.2,0.1c0,0.4,0,0.9,0,1.3
c0,1.5,0,3,0.1,4.5c0.1,2.9,0.6,5.7,1.5,8.4c0.8,2,2,3.5,4.2,3.9c1.6,0.2,3.2,0.1,4.7-0.4c3.5-1.2,6.8-2.7,10-4.6L1123.8,193.5"/>
<path class="st1" d="M654.9,259c-9,0-14-0.2-22,0c-5,0.1-17,0-17.8,1.1c-4.3,6.1-7.5,14.1-8,21.6c-1.1,17-0.4,34.2-0.4,51.8l8,1.1
v8.6c-15.6,5.2-21.9-3.5-21.7-17.2c0.2-15.7-0.2-31.4,0.2-47.1c0.1-6.4,1.3-12.7,2.1-19.5c-7.4-0.6-5.1-6.6-4.5-12.4h89V259"/>
<path class="st1" d="M731.9,259c9,0,27-0.2,35,0c5,0.1,12.2-0.6,14,2c4.3,6.2,7.5,13.4,8,20.8c1.1,17,0.4,34.2,0.4,51.8l-8,1.1
v8.6c15.6,5.2,21.9-3.5,21.7-17.2c-0.2-15.7,0.2-31.4-0.2-47.1c-0.1-6.4-1.3-12.7-2.1-19.5c7.4-0.6,5.1-6.6,4.5-12.4h-89v12"/>
<path class="st1" d="M275.1,286c-7.5,7.5-44.8,44.5-58.3,57.9c2.7-0.3,5.3-0.4,8-0.3c2.1,0.1,4.1,0.4,6.1,0.8
c3.1-3.2,7-7.3,12.1-12.5c8.1-8.3,15.8-16.3,24.2-24.2c4.6-4.3,7-8.7,5.8-15C272.5,290.3,273.2,287.7,275.1,286 M275.8,285.2
c-0.2,0.2-0.5,0.5-0.7,0.8C276.5,284.6,276.9,284.2,275.8,285.2"/>
<path class="st1" d="M390.6,217c0,5.9-4.8,10.6-10.7,10.6c-5.9,0-10.6-4.8-10.6-10.7c0-5.8,4.7-10.5,10.4-10.6
C385.7,206.3,390.5,211.1,390.6,217 M419,217c0-21.6-17.5-39.1-39.1-39.1s-39.1,17.5-39.1,39.1c0,21.6,17.5,39.1,39.1,39.1
S419,238.6,419,217L419,217"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 497.43 228.97"><defs><style>.cls-1{fill:#3f90ce;}.cls-2{opacity:0.15;}.cls-3{fill:#fff;}</style></defs><title>Asset 2</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M0,0H12.25V170H110v11.64H0Z"/><path class="cls-1" d="M138.75,167.24Q122.82,148,122.82,116.39t15.93-50.84q15.93-19,42.27-19T223.29,64q15.92,17.46,15.93,46.55v5.82H135.08q0,26.65,12.55,42.58T181,174.9a50.14,50.14,0,0,0,28.18-8.27A44.23,44.23,0,0,0,227,144.27h12.25q-3.08,18.07-19.61,30T181,186.23Q154.68,186.23,138.75,167.24ZM227,104.75v-4.59a46.49,46.49,0,0,0-14.71-30.63A45.05,45.05,0,0,0,181,57.28a43.4,43.4,0,0,0-30.94,12.56,47.58,47.58,0,0,0-15,31.54v3.37Z"/><path class="cls-1" d="M268,181.64V51.15h12.25V73.21a41.83,41.83,0,0,1,16.85-18.38,46.6,46.6,0,0,1,25.12-7q19.29,0,32.77,12.56Q368.79,73.21,368.78,91v90.67H356.53V91a30.37,30.37,0,0,0-10.11-23,34.12,34.12,0,0,0-24.19-9.5A40.35,40.35,0,0,0,295,68.92a45.21,45.21,0,0,0-14.7,26.65v86.07Z"/><path class="cls-1" d="M409.83,172.75Q395.12,160.51,395.12,143h11.34q0,13.17,11.33,22.67,11.32,9.18,27.26,9.19,18.38,0,29.71-8.27a27,27,0,0,0,11-22.36q0-10.41-11.64-17.77-11.64-7-31.24-8.88-20.84-1.84-33.08-11.64-12.57-9.8-12.56-24.2,0-16.23,12.56-25.11t35.22-8.89q21.75,0,34.61,9.5,13.17,9.5,13.17,25.42H481.19A28.06,28.06,0,0,0,469.55,64q-9.49-6.73-24.5-6.74-16.55,0-26.34,6.74t-9.8,17.76q0,10.43,11.94,17.77,11.64,7,31.25,8.88,20.5,1.84,33.08,12.25,12.24,10.42,12.25,26,0,17.46-14.4,27.87Q468.95,185,445.05,185,424.22,185,409.83,172.75Z"/><g class="cls-2"><polygon class="cls-3" points="433.45 208.56 430.83 208.56 426.32 215.64 426.32 208.55 423.85 208.55 423.85 222.42 426.32 222.42 426.32 219.42 427.97 216.89 430.93 222.42 433.75 222.42 429.35 214.97 433.45 208.56"/><path class="cls-3" d="M441.17,208.56h-3.72a2.26,2.26,0,0,0-2.47,2v9.9a2.26,2.26,0,0,0,2.47,2h3.72a2.28,2.28,0,0,0,2.47-2v-9.9A2.26,2.26,0,0,0,441.17,208.56Zm0,11.88h-3.72v-9.9h3.72Z"/><polygon class="cls-3" points="452.29 216.7 448.79 208.55 446.11 208.55 446.11 222.42 448.59 222.42 448.59 213.34 452.58 222.42 454.77 222.42 454.77 208.55 452.29 208.55 452.29 216.7"/><polygon class="cls-3" points="456.63 210.54 459.72 210.54 459.72 222.42 462.2 222.42 462.2 210.54 465.28 210.54 465.28 208.55 456.63 208.55 456.63 210.54"/><polygon class="cls-3" points="467.14 222.42 474.56 222.42 474.56 220.44 469.61 220.44 469.61 216.08 473.32 216.08 473.32 214.1 469.61 214.1 469.61 210.53 474.56 210.53 474.56 208.55 467.14 208.55 467.14 222.42"/><polygon class="cls-3" points="482.89 208.55 482.89 216.7 479.38 208.55 476.71 208.55 476.71 222.42 479.18 222.42 479.18 213.34 483.18 222.42 485.36 222.42 485.36 208.55 482.89 208.55"/><path class="cls-3" d="M493.72,208.55h-3.11l-3.71,13.87h2.62l.49-2h4.31l.49,2h2.62Zm-3.21,9.91,1.7-6.45,1.61,6.45Z"/><polygon class="cls-3" points="405.98 201.77 402.06 204.04 398.13 206.31 394.2 208.57 394.2 213.11 394.2 217.64 394.2 222.17 398.13 224.44 398.13 219.91 398.13 215.37 398.13 210.84 402.06 208.57 402.06 213.11 402.06 213.11 402.06 217.64 405.98 215.37 405.98 215.37 409.91 213.11 409.91 208.57 413.83 206.31 409.91 204.04 405.98 201.77"/><polygon class="cls-3" points="413.83 210.84 413.83 215.37 409.91 217.64 413.83 219.91 413.83 224.44 417.76 222.17 417.76 217.64 417.76 213.11 417.76 208.57 413.83 206.31 413.83 210.84"/><polygon class="cls-3" points="405.98 219.91 402.06 222.17 402.06 222.17 402.06 226.71 405.98 228.97 409.91 226.71 409.91 222.17 405.98 219.91 405.98 219.91"/></g><g class="cls-2"><path class="cls-3" d="M374.77,219.07a3.08,3.08,0,0,1-1.11-1.28v1.5h-.72V208.56h.72v4.76a2.82,2.82,0,0,1,2.7-1.86,3,3,0,0,1,2.43,1.1,5.19,5.19,0,0,1,0,5.9,3,3,0,0,1-2.43,1.1A2.79,2.79,0,0,1,374.77,219.07Zm3.54-1.06a4.65,4.65,0,0,0,0-5,2.37,2.37,0,0,0-1.95-.94,2.33,2.33,0,0,0-1.9,1,3.8,3.8,0,0,0-.8,2.43,3.75,3.75,0,0,0,.8,2.42,2.32,2.32,0,0,0,1.9,1A2.37,2.37,0,0,0,378.31,218Z"/><path class="cls-3" d="M381.23,221.64h.56c.28,0,.58-.22.9-.67a7.1,7.1,0,0,0,.89-1.75l-2.95-7.64h.67l2.62,6.75,2.61-6.75h.71l-3.32,8.54a4.33,4.33,0,0,1-.88,1.51,1.65,1.65,0,0,1-1.12.56h-.69Z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

+274
View File
@@ -0,0 +1,274 @@
<template>
<div class="content">
<b-container fluid class="h-100">
<b-row align-h="around">
<b-col lg="7">
<div class="card">
<h2>Add Cluster</h2>
<div class="add-cluster">
<b-form @submit.prevent="doAddCluster">
<b-form-group
label="Choose config:"
>
<b-form-select
v-model="kubecontext"
:options="contextNames"
@change="onSelect($event)"
/>
<b-button v-b-toggle.collapse-advanced variant="link">
Proxy settings
</b-button>
</b-form-group>
<b-collapse id="collapse-advanced">
<b-form-group
label="HTTP Proxy server. Used for communicating with Kubernetes API."
description="A HTTP proxy server URL (format: http://<address>:<port>)."
>
<b-form-input
v-model="httpsProxy"
/>
</b-form-group>
</b-collapse>
<b-form-group
label="Kubeconfig:"
v-if="status === 'ERROR' || kubecontext === 'custom'"
>
<div class="editor">
<prism-editor v-model="clusterconfig" language="yaml" />
</div>
</b-form-group>
<b-alert variant="danger" show v-if="status === 'ERROR'">
{{ errorMsg }}
<div v-if="errorDetails !== ''">
<b-button v-b-toggle.collapse-error variant="link" size="sm">
Show details
</b-button>
<b-collapse id="collapse-error">
<code>
{{ errorDetails }}
</code>
</b-collapse>
</div>
</b-alert>
<b-form-row>
<b-col>
<b-button variant="primary" type="submit" :disabled="clusterconfig === ''">
<b-spinner small v-if="isProcessing" label="Small Spinner" />
{{ addButtonText }}
</b-button>
</b-col>
</b-form-row>
</b-form>
</div>
</div>
</b-col>
<b-col lg="5" class="help d-none d-lg-block">
<h3>Clusters associated with Lens</h3>
<p>
Add clusters by clicking the <span class="text-primary">Add Cluster</span> button.
You'll need to obtain a working kubeconfig for the cluster you want to add.
</p>
<p>
Each <a href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#context">cluster context</a> is added as a separate item in the left-side cluster menu to allow you to operate easily on multiple clusters and/or contexts.
</p>
<p>
For more information on kubeconfig see <a href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/">Kubernetes docs</a>
</p>
<p>
NOTE: Any manually added cluster is not merged into your kubeconfig file.
</p>
<p>
To see your currently enabled config with <code>kubectl</code>, use <code>kubectl config view --minify --raw</code> command in your terminal.
</p>
<p>
When connecting to a cluster, make sure you have a valid and working kubeconfig for the cluster. Following lists known "gotchas" in some authentication types used in kubeconfig with Lens app.
</p>
<a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#option-1-oidc-authenticator">
<h4>OIDC (OpenID Connect)</h4>
</a>
<div>
<p>
When connecting Lens to OIDC enabled cluster, there's few things you as a user need to take into account.
</p>
<b>Dedicated refresh token</b>
<p>
As Lens app utilized kubeconfig is "disconnected" from your main kubeconfig Lens needs to have it's own refresh token it utilizes.
If you share the refresh token with e.g. <code>kubectl</code> who ever uses the token first will invalidate it for the next user.
One way to achieve this is with <a href="https://github.com/int128/kubelogin">kubelogin</a> tool by removing the tokens (both <code>id_token</code> and <code>refresh_token</code>) from the config and issuing <code>kubelogin</code> command. That'll take you through the login process and will result you having "dedicated" refresh token.
</p>
</div>
<h4>Exec auth plugins</h4>
<p>
When using <a href="https://kubernetes.io/docs/reference/access-authn-authz/authentication/#configuration">exec auth</a> plugins make sure the paths that are used to call any binaries are full paths as Lens app might not be able to call binaries with relative paths. Make also sure that you pass all needed information either as arguments or env variables in the config, Lens app might not have all login shell env variables set automatically.
</p>
</b-col>
</b-row>
</b-container>
</div>
</template>
<script>
import * as PrismEditor from 'vue-prism-editor'
import * as k8s from "@kubernetes/client-node"
import { dumpConfigYaml } from "../../main/k8s"
import ClustersMixin from "@/mixins/ClustersMixin";
class ClusterAccessError extends Error {}
export default {
name: 'AddClusterPage',
mixins: [ClustersMixin],
props: { },
components: {
PrismEditor,
},
data(){
return {
clusterconfig: "",
httpsProxy: "",
kubecontext: "",
status: "",
errorMsg: "",
errorCluster: "",
errorDetails: "",
seenContexts: []
}
},
mounted: function() {
this.$store.dispatch("reloadAvailableKubeContexts");
this.seenContexts = JSON.parse(JSON.stringify(this.$store.getters.seenContexts)) // clone seenContexts from store
this.storeSeenContexts()
},
computed: {
isProcessing: function() {
return this.status === "PROCESSING";
},
addButtonText: function() {
if (this.kubecontext === "custom") {
return "Add Cluster(s)"
} else {
return "Add Cluster"
}
},
contextNames: function() {
const configs = this.availableContexts
const names = configs.map((kc) => {
return { text: kc.currentContext + (this.isNewContext(kc.currentContext) ? " (new)": ""), value: dumpConfigYaml(kc) }
})
names.unshift({text: "Select kubeconfig", value: ""})
names.push({text: "Custom ...", value: "custom"})
return names;
},
},
methods: {
isNewContext(context) {
return this.newContexts.indexOf(context) > -1
},
storeSeenContexts() {
const configs = this.$store.getters.availableKubeContexts
const contexts = configs.map((kc) => {
return kc.currentContext
})
this.$store.dispatch("addSeenContexts", contexts)
},
onSelect: function() {
this.status = "";
if (this.kubecontext === "custom") {
this.clusterconfig = "";
} else {
this.clusterconfig = this.kubecontext;
}
},
isOidcAuth: function(authProvider) {
if (!authProvider) { return false }
if (authProvider.name === "oidc") { return true }
return false;
},
doAddCluster: async function() {
// Clear previous error details
this.errorMsg = ""
this.errorCluster = ""
this.errorDetails = ""
this.status = "PROCESSING"
try {
const kc = new k8s.KubeConfig();
kc.loadFromString(this.clusterconfig); // throws TypeError if we cannot parse kubeconfig
const clusterInfo = {
kubeConfig: dumpConfigYaml(kc),
preferences: {
clusterName: kc.currentContext
},
workspace: this.$store.getters.currentWorkspace.id
}
if (this.httpsProxy) {
clusterInfo.preferences.httpsProxy = this.httpsProxy
}
let res = await this.$store.dispatch('addCluster', clusterInfo)
console.log("addCluster result:", res)
if(!res){
this.status = "ERROR";
return false;
}
this.status = "SUCCESS"
this.$router.push({
name: "cluster-page",
params: {
id: res.id
},
}).catch((err) => {})
} catch (error) {
console.log("addCluster raised:", error)
if(typeof error === 'string') {
this.errorMsg = error;
} else if(error instanceof TypeError) {
this.errorMsg = "cannot parse kubeconfig";
} else if(error.response && error.response.statusCode === 401) {
this.errorMsg = "invalid kubeconfig (access denied)"
} else if(error.message) {
this.errorMsg = error.message
} else if(error instanceof ClusterAccessError) {
this.errorMsg = `Invalid kubeconfig context ${error.context}`
this.errorCluster = error.cluster
this.errorDetails = error.details
}
this.status = "ERROR";
return false;
}
return true;
}
}
}
</script>
<style scoped lang="scss">
.help{
border-left: 1px solid #353a3e;
padding-top: 20px;
&:first-child{
padding-top: 0;
}
h3{
padding: 0.75rem 0 0.75rem 0;
}
height: 100vh;
overflow-y: auto;
}
h2{
padding: 0.75rem;
}
.card {
margin-top: 20px;
}
.add-cluster {
padding: 0.75rem;
}
.btn-link {
padding-left: 0;
}
</style>
@@ -0,0 +1,115 @@
<template>
<div class="content">
<ClosePageButton />
<div class="container-fluid lens-workspaces">
<div class="header sticky-top">
<h2><i class="material-icons">layers</i> Workspaces</h2>
</div>
<div class="row">
<div class="col-3" />
<div class="col-6">
<h2>New Workspace</h2>
<b-form @submit.prevent="createWorkspace">
<b-form-group
label="Name:"
label-for="input-name"
>
<b-form-input
id="input-name"
v-model="workspace.name"
trim
/>
</b-form-group>
<b-form-group
label="Description:"
label-for="input-description"
>
<b-form-input
id="input-description"
v-model="workspace.description"
trim
/>
</b-form-group>
<b-form-row>
<b-col>
<b-button variant="primary" type="submit">
Create Workspace
</b-button>
</b-col>
</b-form-row>
</b-form>
</div>
<div class="col-3" />
</div>
</div>
</div>
</template>
<script>
import { v4 as uuid } from "uuid";
import ClosePageButton from "@/components/common/ClosePageButton";
export default {
name: 'AddWorkspacePage',
components: {
ClosePageButton
},
data() {
return {
workspace: {
id: uuid(),
name: "",
description: ""
},
errors: {
name: null,
description: null
}
}
},
computed: {
},
methods: {
createWorkspace: function() {
this.$store.commit("addWorkspace", this.workspace)
this.$router.push({
name: "workspaces-page"
})
}
},
mounted: function() {
this.$store.commit("hideMenu");
},
destroyed: function() {
this.$store.commit("showMenu");
}
}
</script>
<style lang="scss" scoped>
#app > .main-view > .content {
left: 70px;
right: 70px;
}
h2 {
i {
position: relative;
top: 6px;
}
}
.lens-workspaces {
height: 100%;
overflow-y: scroll;
& input {
background-color: #252729 !important;
border: 0px !important;
color: #87909c !important;
}
.header {
padding-top: 15px;
}
}
</style>
@@ -0,0 +1,88 @@
<template>
<div class="bottom-bar">
<div id="workspace-area">
<i class="material-icons">layers</i> {{ currentWorkspace.name }}
</div>
<b-popover target="workspace-area" triggers="click" placement="top" :show.sync="show">
<template v-slot:title>
<a href="#" @click.prevent="goWorkspaces"><i class="material-icons">layers</i> Workspaces</a>
</template>
<ul
v-for="workspace in workspaces"
:key="workspace.id"
:workspace="workspace"
class="list-group list-group-flush"
>
<li class="list-group-item">
<a href="#" @click.prevent="switchWorkspace(workspace)">{{ workspace.name }}</a>
</li>
</ul>
</b-popover>
</div>
</template>
<script>
export default {
name: "BottomBar",
data() {
return {
show: false
}
},
computed: {
currentWorkspace: function() {
return this.$store.getters.currentWorkspace;
},
workspaces: function() {
return this.$store.getters.workspaces;
}
},
methods: {
switchWorkspace: function(workspace) {
this.show = false;
this.$store.commit("setCurrentWorkspace", workspace);
this.$store.dispatch("clearClusters");
this.$store.dispatch("refreshClusters", workspace);
this.$router.push({
name: "landing-page"
})
},
goWorkspaces: function() {
this.$router.push({
name: "workspaces-page"
})
}
}
}
</script>
<style scoped lang="scss">
.bottom-bar {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 20px;
background-color: var(--kontena-blue);
z-index: 2000;
}
#workspace-area {
position: absolute;
bottom: 2px;
right: 10px;
display: block;
color: #fff;
opacity: 0.9;
font-size: 11px;
cursor: pointer;
&.active{
opacity: 1.0;
}
i {
position: relative;
top: 4px;
font-size: 14px;
}
}
</style>
+186
View File
@@ -0,0 +1,186 @@
<template>
<div class="content">
<div class="h-100">
<div class="wrapper" v-if="status === 'LOADING'">
<cube-spinner text="" />
<div class="auth-output">
<!-- eslint-disable-next-line vue/no-v-html -->
<pre class="auth-output" v-html="authOutput" />
</div>
</div>
<div class="wrapper" v-if="status === 'ERROR'">
<div class="error">
<i class="material-icons">{{ error_icon }}</i>
<div class="text-center">
<h2>{{ cluster.preferences.clusterName }}</h2>
<!-- eslint-disable-next-line vue/no-v-html -->
<pre v-html="authOutput" />
<b-button variant="link" @click="tryAgain">
Reconnect
</b-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import CubeSpinner from "@/components/CubeSpinner";
import { webviewTag } from 'electron';
export default {
name: "ClusterPage",
components: {
CubeSpinner
},
data(){
return {
authOutput: ""
}
},
computed: {
cluster: function() {
return this.$store.getters.clusterById(this.$route.params.id);
},
online: function() {
if (!this.cluster) { return false }
return this.cluster.online;
},
accessible: function() {
if (!this.cluster) { return false }
return this.cluster.accessible;
},
lens: function() {
return this.$store.getters.lensById(this.cluster.id);
},
status: function() {
if (this.cluster) {
if (this.cluster.accessible && this.lens.loaded === true) {
return "SUCCESS";
} else if (this.cluster.accessible === false) {
return "ERROR";
}
return "LOADING";
}
return "ERROR";
},
error_icon: function() {
if (!this.cluster.online) {
return "cloud_off"
} else {
return "https"
}
}
},
methods: {
tryAgain: function() {
this.authOutput = ""
this.cluster.accessible = null
setTimeout(() => {
this.loadLens()
}, 1000)
},
loadLens: function() {
this.authOutput = "Connecting ...\n";
this.$promiseIpc.on(`kube-auth:${this.cluster.id}`, (output) => {
this.authOutput += output.data;
})
this.toggleLens();
return this.$store.dispatch("refineCluster", this.$route.params.id);
},
lensLoaded: function() {
console.log("lens loaded")
this.lens.loaded = true;
this.$store.commit("updateLens", this.lens);
this.lens.webview.getWebContents().executeJavaScript(
// disable dragging of ui links
'document.styleSheets[0].insertRule("*, *::after, *::before { -webkit-user-drag: none; -webkit-app-region: no-drag; }", 1);'
);
},
addWebview: function() {
const container = document.getElementById("lens-container");
if (!container || !this.lens.webview) {
return
}
container.style = "display: block;"
let webview = null
container.childNodes.forEach((child) => {
if (child === this.lens.webview) {
webview = child
}
})
if (!webview) {
container.appendChild(this.lens.webview)
}
container.childNodes.forEach((child) => {
if (child !== this.lens.webview) {
child.style = "display: none;"
} else {
child.style = "top: 0; bottom: 20px;"
}
})
},
// Called only when online state changes
toggleLens: function() {
if (!this.lens) { return }
if (this.accessible) {
this.activateLens();
} else {
this.hideLens();
}
},
activateLens: async function() {
console.log("activate lens")
if (!this.lens.webview) {
console.log("create webview")
const webview = document.createElement('webview');
webview.addEventListener('did-finish-load', this.lensLoaded);
webview.src = this.cluster.url;
this.lens.webview = webview;
this.$store.dispatch("attachWebview", this.lens);
}
this.$store.dispatch("attachWebview", this.lens);
this.$tracker.event("cluster", "open");
},
hideLens: function() {
this.$store.dispatch("hideWebviews");
}
},
created() {
this.loadLens();
},
destroyed() {
this.hideLens();
},
watch: {
"$route": "loadLens",
"online": "toggleLens",
"cluster": "toggleLens",
"accessible": function(newStatus, oldStatus) {
console.log("accessible watch, vals:", newStatus, oldStatus);
if(newStatus === false) { // accessble == false
this.$tracker.event("cluster", "open-failed");
}
},
}
};
</script>
<style scoped lang="scss">
div.auth-output {
padding-top: 250px;
width: 70%;
pre {
height: 100px;
text-align: center;
}
}
.error {
width: 90%;
}
pre {
font-size: 80%;
overflow: auto;
max-height: 150px;
}
</style>
@@ -0,0 +1,134 @@
<template>
<div class="settings-section">
<b>Metrics</b>
<p class="description">
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/kontena/lens/tree/master/app/manifests/metrics">here</a>.
</p>
<div class="actions">
<b-button @click="install" v-if="!settings.installed" :disabled="!cluster.isAdmin || isProcessing || !canInstall" variant="primary">
<b-spinner small v-if="isProcessing" label="Small Spinner" />
Install
</b-button>
<b-button @click="uninstall" v-if="settings.installed" :disabled="!cluster.isAdmin || isProcessing" variant="danger">
<b-spinner small v-if="isProcessing" label="Small Spinner" />
Uninstall
</b-button>
<b-button @click="upgrade" v-if="isUpgradeAvailable" :disabled="!cluster.isAdmin" variant="primary">
Upgrade
</b-button>
<b-alert show variant="danger" v-if="status === 'ERROR'">
{{ errorMsg }}
</b-alert>
</div>
</div>
</template>
<script>
const semver = require('semver');
export default {
name: 'Metrics',
components: {},
props: {
cluster: {
type: Object,
required: true,
default: null
},
feature: {
type: String,
required: true,
default: null
},
settings: {
type: Object,
required: false,
default: Object
}
},
data(){
return {
status: "",
errorMsg: "",
}
},
computed:{
isUpgradeAvailable: function() {
if(!this.settings.installed) return false;
if(!this.settings.currentVersion) return false;
if(!this.settings.latestVersion) return false;
let currentVersion = (this.settings.currentVersion.charAt(0) === "v") ? this.settings.currentVersion.substr(1) : this.settings.currentVersion;
let latestVersion = (this.settings.latestVersion.charAt(0) === "v") ? this.settings.latestVersion.substr(1) : this.settings.latestVersion;
return semver.gt(latestVersion, currentVersion)
},
isProcessing: function() {
return this.status === "PROCESSING";
},
canInstall: function() {
return !this.cluster.preferences.prometheus
}
},
methods: {
install: async function(){
this.status="PROCESSING";
let error = null;
let result = await this.$store.dispatch("installClusterFeature", {
name: this.feature,
clusterId: this.cluster.id,
config: null,
}).catch(e => {
error = e;
return false; // just returning false to promise
})
// handle exceptions here; but oh why, why do we have to???
if(error){
this.status = "ERROR";
this.errorMsg = error;
return false;
}
// now let's look at result...
if(result.success === false) {
this.status = "ERROR"
this.errorMsg = result.message
} else {
this.$store.dispatch("refineCluster", this.cluster.id);
this.status = "";
this.errorMsg = "";
}
},
uninstall: async function(){
this.status="PROCESSING";
let error = null;
try {
let result = await this.$store.dispatch("uninstallClusterFeature", {
name: this.feature,
clusterId: this.cluster.id,
})
console.log("uninstall result:", result);
this.$store.dispatch("refineCluster", this.cluster.id);
this.status="SUCCESS";
this.errorMsg = "";
} catch(error) {
this.status = "ERROR"
this.errorMsg = error
console.log("Error uninstalling:", error);
}
return true;
},
upgrade: async function(){
// todo
return true;
},
},
mounted: function(){
console.log(this.settings);
}
}
</script>
<style scoped lang="scss">
</style>
@@ -0,0 +1,131 @@
<template>
<div class="settings-section">
<b>User Mode</b>
<p class="description">
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>
<div class="actions">
<b-button @click="install" v-if="!settings.installed" :disabled="!cluster.isAdmin || isProcessing" variant="primary">
<b-spinner small v-if="isProcessing" label="Small Spinner" />
Install
</b-button>
<b-button @click="uninstall" v-if="settings.installed" :disabled="!cluster.isAdmin || isProcessing" variant="danger">
<b-spinner small v-if="isProcessing" label="Small Spinner" />
Uninstall
</b-button>
<b-button @click="upgrade" v-if="isUpgradeAvailable" :disabled="!cluster.isAdmin" variant="primary">
Upgrade
</b-button>
<b-alert show variant="danger" v-if="status === 'ERROR'">
{{ errorMsg }}
</b-alert>
</div>
</div>
</template>
<script>
const semver = require('semver');
export default {
name: 'UserMode',
components: {},
props: {
cluster: {
type: Object,
required: true,
default: null
},
feature: {
type: String,
required: true,
default: null
},
settings: {
type: Object,
required: false,
default: Object
}
},
data(){
return {
status: "",
errorMsg: "",
}
},
computed:{
isProcessing: function() {
return this.status === "PROCESSING";
},
isUpgradeAvailable: function() {
if(!this.settings.installed) return false;
if(!this.settings.currentVersion) return false;
if(!this.settings.latestVersion) return false;
let currentVersion = (this.settings.currentVersion.charAt(0) === "v") ? this.settings.currentVersion.substr(1) : this.settings.currentVersion;
let latestVersion = (this.settings.latestVersion.charAt(0) === "v") ? this.settings.latestVersion.substr(1) : this.settings.latestVersion;
return semver.gt(latestVersion, currentVersion)
},
},
methods: {
install: async function(){
this.status="PROCESSING";
let error = null;
let result = await this.$store.dispatch("installClusterFeature", {
name: this.feature,
clusterId: this.cluster.id,
config: null,
}).catch(e => {
error = e;
return false; // just returning false to promise
})
// handle exceptions here; but oh why, why do we have to???
if(error){
this.status = "ERROR";
this.errorMsg = error;
return false;
}
// now let's look at result...
if(result.success === false) {
this.status = "ERROR"
this.errorMsg = result.message
} else {
this.$store.dispatch("refineCluster", this.cluster.id);
this.status = "";
this.errorMsg = "";
}
},
uninstall: async function(){
this.status="PROCESSING";
let error = null;
try {
let result = await this.$store.dispatch("uninstallClusterFeature", {
name: this.feature,
clusterId: this.cluster.id,
})
console.log("uninstall result:", result);
this.$store.dispatch("refineCluster", this.cluster.id);
this.status="SUCCESS";
this.errorMsg = "";
} catch(error) {
this.status = "ERROR"
this.errorMsg = error
console.log("Error uninstalling:", error);
}
return true;
},
upgrade: async function(){
// todo
return true;
},
},
mounted: function(){
console.log(this.settings);
}
}
</script>
<style scoped lang="scss">
</style>
@@ -0,0 +1,7 @@
import Metrics from './Metrics'
import UserMode from './UserMode'
export default {
Metrics,
UserMode
}
@@ -0,0 +1,51 @@
<template>
<div id="cluster-features" class="row">
<div class="col-12">
<h2>Features</h2>
<component
v-for="featureName in features"
:key="featureName"
:is="featureName"
:cluster="cluster"
:feature="featureName"
:settings="feature(featureName)"
/>
</div>
</div>
</template>
<script>
// register all sub components
import components from './Components'
export default {
name: 'ClusterFeatures',
components: components,
props: {
cluster: {
type: Object,
default: null,
}
},
data(){
return {
}
},
computed:{
features: function() {
if (!this.cluster) return []
if (!this.cluster.features) return []
return Object.keys(this.cluster.features)
}
},
methods: {
feature: function( name ){ return this.cluster.features[name] }
}
}
</script>
<style scoped lang="scss">
h2{
margin-top: 20px;
}
</style>
@@ -0,0 +1,148 @@
<template>
<div class="settings-section">
<b>Cluster Icon</b>
<p>
Define cluster icon. By default automatically generated.
</p>
<div class="row">
<span class="cluster-settings-icon">
<img v-if="preferences.icon" :src="preferences.icon" class="cluster-icon">
<hashicon v-else :name="cluster.preferences.clusterName" size="38" />
</span>
<div class="col">
<b-form-file
ref="fileUpload"
v-model="file"
accept="image/jpeg, image/png, image/gif"
:state="Boolean(file)"
placeholder="Browse for new icon..."
drop-placeholder="Drop file here..."
browse-text=""
@input="upload"
/>
</div>
</div>
<div class="mt-3">
<a href="#" @click.prevent="reset" v-if="preferences.icon" class="text-muted">
Use automatically generated icon
</a>
<b-alert show variant="danger" v-if="status === 'ERROR'">
{{ errorMsg }}
</b-alert>
</div>
</div>
</template>
<script>
import hashicon from "../../hashicon/hashicon";
export default {
name: 'ClusterIcon',
components: {
hashicon
},
props: {
cluster: {
type: Object,
required: true,
default: null
}
},
data(){
return {
status: "",
errorMsg: "",
file: null,
}
},
computed:{
preferences: function() {
return this.cluster.preferences;
},
isProcessing: function() {
return this.status === "PROCESSING";
},
isResetProcessing: function() {
return this.resetStatus === "PROCESSING";
},
},
methods: {
reset: async function(){
this.resetStatus = "PROCESSING";
let error = null;
let result = await this.$store.dispatch("resetClusterIcon", {
clusterId: this.cluster.id
}).catch(e => {
error = e;
return false; // just returning false to promise
})
// handle exceptions here; but oh why, why do we have to???
if(error){
this.resetStatus = "ERROR";
this.errorMsg = error;
return false;
}
// now let's look at result...
if(result.success === false) {
this.resetStatus = "ERROR"
this.errorMsg = result.message
} else {
this.resetStatus = "";
this.errorMsg = "";
}
},
upload: async function(file){
if(!file) {
return
}
this.status="PROCESSING";
let error = null;
let result = await this.$store.dispatch("uploadClusterIcon", {
clusterId: this.cluster.id,
name: file.name,
path: file.path
}).catch(e => {
error = e;
return false; // just returning false to promise
})
// handle exceptions here; but oh why, why do we have to???
if(error){
this.status = "ERROR";
this.errorMsg = error;
return false;
}
// now let's look at result...
if(result.success === false) {
this.status = "ERROR"
this.errorMsg = result.message
} else {
this.status = "";
this.errorMsg = "";
this.file = null;
}
},
},
mounted: function(){
}
}
</script>
<style scoped lang="scss">
.description{
padding: 0.75rem;
}
.actions{
border-top: 1px solid rgba(255,255,255,0.10);
padding: 15px;
}
.cluster-settings-icon {
background: #252729;
margin-left: 15px;
padding: 5px;
}
</style>
@@ -0,0 +1,49 @@
<template>
<div class="settings-section">
<b>Cluster Name</b>
<p>
Define cluster name.
</p>
<b-form-input v-model="cluster.preferences.clusterName"
:placeholder="cluster.preferences.clusterName"
:state="errors.name"
trim
@blur="onSave"
/>
</div>
</template>
<script>
export default {
name: 'ClusterName',
props: {
cluster: {
type: Object,
required: true,
default: null
}
},
data(){
return {
errors: {
name: null
}
}
},
methods: {
onSave: function() {
if(this.cluster.preferences.clusterName === "") {
this.errors.name = false
return
} else {
this.errors.name = true
}
this.$store.dispatch("storeCluster", this.cluster);
}
}
}
</script>
<style>
</style>
@@ -0,0 +1,52 @@
<template>
<div class="settings-section">
<b>Cluster Workspace</b>
<p>
Define cluster <a href="/#/workspaces">workspace</a>.
</p>
<b-form-select
v-model="cluster.workspace"
:options="workspaces"
@change="onSave"
/>
</div>
</template>
<script>
export default {
name: 'ClusterWorkspace',
props: {
cluster: {
type: Object,
required: true,
default: null
}
},
computed: {
workspaces: function() {
return this.$store.getters.workspaces.map((ws) => {
return {
value: ws.id,
text: ws.name
}
});
}
},
methods: {
onSave: async function(workspaceId) {
this.cluster.workspace = workspaceId
await this.$store.dispatch("storeCluster", this.cluster);
const ws = this.$store.getters.workspaceById(this.cluster.workspace);
if (ws) {
this.$store.commit("setCurrentWorkspace", ws);
await this.$store.dispatch("clearClusters");
await this.$store.dispatch("refreshClusters", ws);
}
}
}
}
</script>
<style>
</style>
@@ -0,0 +1,35 @@
<template>
<div id="cluster-general" class="row">
<div class="col-12">
<h2>General</h2>
<cluster-name :cluster="cluster" />
<cluster-workspace :cluster="cluster" />
<cluster-icon :cluster="cluster" />
</div>
</div>
</template>
<script>
import ClusterIcon from "./ClusterIcon";
import ClusterName from "./ClusterName"
import ClusterWorkspace from "./ClusterWorkspace"
export default {
name: "ClusterSettingsGeneral",
props: {
cluster: {
type: Object,
required: true,
default: null
}
},
components: {
ClusterIcon,
ClusterName,
ClusterWorkspace
}
}
</script>
<style>
</style>
@@ -0,0 +1,69 @@
<template>
<div id="cluster-status" class="cluster-settings-overview row">
<div class="col-12">
<h2>Status</h2>
<div class="settings-section">
<b>Cluster Status</b>
<p>
Cluster status information including detected distribution, kernel version and online status
</p>
<table class="table">
<tbody>
<tr>
<th scope="row">
Online Status
</th>
<td>{{ cluster.online ? "online" : "offline (" + (cluster.failureReason || "unknown reason") + ")" }}</td>
</tr>
<tr>
<th scope="row">
Distribution
</th>
<td>{{ cluster.distribution }}</td>
</tr>
<tr>
<th scope="row">
Kernel Version
</th>
<td>{{ cluster.version }}</td>
</tr>
<tr>
<th scope="row">
API Address
</th>
<td>{{ cluster.apiUrl }}</td>
</tr>
<tr v-if="cluster.nodes">
<th scope="row">
Nodes Count
</th>
<td>{{ cluster.nodes }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ClusterSettingsOverview',
props: {
cluster: {
type: Object,
default: null,
}
},
data(){
return {
}
},
methods: {
}
}
</script>
<style lang="scss">
</style>
@@ -0,0 +1,134 @@
<template>
<div class="row">
<div class="col-12">
<div class="cluster-settings-section">
<b>HTTP Proxy</b>
<b-form-group
label="HTTP Proxy server. Used for communicating with Kubernetes API."
description="A HTTP proxy server URL (format: http://<address>:<port>)."
>
<b-form-input
v-model="cluster.preferences.httpsProxy"
id="input-httpsproxy"
@blur="onHttpsProxySave"
/>
</b-form-group>
</div>
</div>
<div class="col-12">
<div class="cluster-settings-section">
<b>Prometheus</b>
<p>Use pre-installed Prometheus service for metrics. Please refer to the <a href="https://github.com/kontena/lens/blob/master/troubleshooting/custom-prometheus.md">guide</a> for possible configuration changes.</p>
<b-form-group
label="Prometheus service address."
description="A path to an existing Prometheus installation (<namespace>/<service>:<port>)."
>
<b-form-input
v-model="prometheusPath"
placeholder="lens-metrics/prometheus:80"
id="input-prometheuspath"
@blur="onPrometheusSave"
/>
</b-form-group>
</div>
</div>
<div class="col-12">
<div class="cluster-settings-section">
<b>Working Directory</b>
<b-form-group
label="Terminal working directory."
description="An explicit start path where the terminal will be launched, this is used as the current working directory (cwd) for the shell process."
>
<b-form-input
v-model="cluster.preferences.terminalCWD"
placeholder="$HOME"
id="input-terminalcwd"
@blur="onTerminalCwdSave"
:state="errors.terminalcwd"
:formatter="expandPath"
/>
</b-form-group>
</div>
</div>
</div>
</template>
<script>
import { lstatSync } from "fs"
export default {
name: 'ClusterSettingsPreferences',
props: {
cluster: {
type: Object,
default: null,
}
},
data(){
return {
errors: {
terminalcwd: null
},
prometheusPath: ""
}
},
mounted: function() {
if (this.cluster.preferences.prometheus) {
const prom = this.cluster.preferences.prometheus;
this.prometheusPath = `${prom.namespace}/${prom.service}:${prom.port}`
}
},
computed: {
},
methods: {
parsePrometheusPath: function(path) {
let parsed = path.split(/\/|:/)
return {
namespace: parsed[0],
service: parsed[1],
port: parsed[2]
}
},
expandPath: function(value, event) {
if(value === "") {
this.errors.terminalcwd = null
return value;
}
if(value.substr(0, 1) == "~") {
value = process.env.HOME + value.substr(1);
} else if(value.substr(0, 5) == "$HOME") {
value = process.env.HOME + value.substr(5);
}
try {
this.errors.terminalcwd = lstatSync(value).isDirectory()
} catch(_err) {
this.errors.terminalcwd = false
}
return value;
},
onHttpsProxySave: function() {
if(this.cluster.preferences.httpsProxy === "") this.cluster.preferences.httpsProxy = null
this.$store.dispatch("storeCluster", this.cluster);
},
onPrometheusSave: function() {
if (this.prometheusPath === "") {
this.cluster.preferences.prometheus = null;
} else {
this.cluster.preferences.prometheus = this.parsePrometheusPath(this.prometheusPath);
}
this.$store.dispatch("storeCluster", this.cluster);
},
onTerminalCwdSave: function() {
if(this.cluster.preferences.terminalCWD === "") this.cluster.preferences.terminalCWD = null
this.$store.dispatch("storeCluster", this.cluster);
}
}
}
</script>
<style lang="scss">
</style>
@@ -0,0 +1,185 @@
<template>
<div class="content">
<ClosePageButton />
<div class="container-fluid settings">
<div v-if="cluster" class="header sticky-top">
<h2>
<div class="icon d-inline">
<img v-if="preferences.icon" :src="preferences.icon" class="cluster-icon">
<hashicon v-else :name="cluster.preferences.clusterName" size="30" />
</div>
{{ cluster.preferences.clusterName }}
</h2>
</div>
<div v-if="cluster" class="row">
<div class="col-3" />
<div ref="content" id="cluster-settings" class="col-6">
<cluster-settings-overview :cluster="cluster" />
<cluster-settings-general :cluster="cluster" />
<cluster-settings-preferences :cluster="cluster" />
<cluster-settings-features :cluster="cluster" />
<div id="cluster-remove" class="settings-section">
<h2>Remove Cluster</h2>
<b-button v-b-modal.bv-modal-confirm variant="danger" type="submit">
Remove Cluster
</b-button>
<!-- Modal confirmation -->
<b-modal id="bv-modal-confirm" @ok="removeCluster" ok-title="Remove" ok-variant="danger" title-class="confirm-header" hide-backdrop title="Confirm cluster delete">
<p>Are you sure you want to delete <strong>{{ preferences.clusterName }}</strong> cluster from Lens?</p>
</b-modal>
</div>
</div>
</div>
<div v-if="!cluster">
<cube-spinner text="Loading..." />
</div>
</div>
</div>
</template>
<script>
import CubeSpinner from "@/components/CubeSpinner";
import ClusterSettingsGeneral from "./General";
import ClusterSettingsOverview from "./Overview";
import ClusterSettingsPreferences from "./Preferences";
import ClusterSettingsFeatures from "./Features";
import hashicon from "../hashicon/hashicon";
import ClosePageButton from "@/components/common/ClosePageButton";
export default {
name: 'ClusterSettingsPage',
components: {
CubeSpinner,
ClosePageButton,
ClusterSettingsGeneral,
ClusterSettingsOverview,
ClusterSettingsPreferences,
ClusterSettingsFeatures,
hashicon
},
computed: {
cluster: function() {
return this.$store.getters.clusterById(this.$route.params.id);
},
preferences: function() {
return this.cluster.preferences;
}
},
methods: {
loadLens: function() {
this.$store.dispatch("refineCluster", this.cluster.id);
},
removeCluster: async function(){
let res = await this.$store.dispatch('removeCluster', this.cluster.id);
if(!res) return false;
this.$router.push({
name: "landing-page"
}).catch(err => {})
return true;
},
},
created() {
this.loadLens()
},
watch: {
"$route": "loadLens"
}
}
</script>
<style lang="scss">
.header {
& h2 {
padding: 0.5rem
}
padding-top: 5px;
padding-left: 15px;
border-bottom: 1px solid rgb(53, 58, 62);
background-color: #1e2124;
& img {
margin-right: 10px;
height: 35px;
}
.hashicon {
position: relative;
height: 40px;
margin-right: 10px;
& canvas{
position: relative;
bottom: -6px;
}
}
}
.settings {
height: 100%;
overflow-y: scroll;
}
.settings-section {
margin-bottom: 20px;
}
#cluster-settings {
& input {
background-color: #252729 !important;
border: 0px !important;
color: #87909c !important;
}
}
.cluster-icon {
width: 38px;
}
h2{
padding: 0.75rem;
padding-left: 0px;
}
.table th, .table td{
padding-bottom: 0px !important;
}
.custom-select {
-webkit-appearance: inherit;
}
.collapsed > .when-opened, :not(.collapsed) > .when-closed {
display: none;
}
.confirm-header{
color: #dc3545;
}
.navbar {
align-items: flex-start !important;
}
.nav-link {
padding: 0px !important;
&.active {
color: white;
}
}
.custom-file-label{
background: transparent !important;
color: #3d90ce !important;
}
.was-validated .custom-file-input:invalid ~ .custom-file-label, .custom-file-input.is-invalid ~ .custom-file-label {
border-color: transparent !important;
}
.custom-file-label::after {
content: "" !important;
width: 0px;
padding: 0px !important;
background-color: transparent !important;
cursor: pointer;
}
</style>
+116
View File
@@ -0,0 +1,116 @@
<template>
<div class="loading">
<div class="CubeSpinner">
<p>{{ text }}</p>
<div class="sk-cube-grid">
<div class="sk-cube sk-cube1" />
<div class="sk-cube sk-cube2" />
<div class="sk-cube sk-cube3" />
<div class="sk-cube sk-cube4" />
<div class="sk-cube sk-cube5" />
<div class="sk-cube sk-cube6" />
<div class="sk-cube sk-cube7" />
<div class="sk-cube sk-cube8" />
<div class="sk-cube sk-cube9" />
</div>
</div>
</div>
</template>
<script>
export default {
name: 'CubeSpinner',
components: {},
methods: {},
props: {
text: {
type: String,
required: false,
default: "Connecting ..."
}
},
}
</script>
<style scoped lang="scss">
.loading {
position: absolute;
left: 50%;
top: 50%;
margin: -44px;
margin-top: -70px;
z-index: 1000;
}
/* http://tobiasahlin.com/spinkit/ */
.CubeSpinner {
--size: 70px;
.sk-cube-grid {
width: 70px;
height: 70px;
transform: rotate(45deg);
margin: 24px auto;
border-bottom-left-radius: 3px;
border-top-right-radius: 3px;
overflow: hidden;
}
.sk-cube-grid .sk-cube {
width: 33%;
height: 33%;
float: left;
-webkit-animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out;
animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out;
}
.sk-cube-grid .sk-cube1 {
-webkit-animation-delay: 0.2s;
animation-delay: 0.2s;
}
.sk-cube-grid .sk-cube2 {
-webkit-animation-delay: 0.3s;
animation-delay: 0.3s;
}
.sk-cube-grid .sk-cube3 {
-webkit-animation-delay: 0.4s;
animation-delay: 0.4s;
}
.sk-cube-grid .sk-cube4 {
-webkit-animation-delay: 0.1s;
animation-delay: 0.1s;
}
.sk-cube-grid .sk-cube5 {
-webkit-animation-delay: 0.2s;
animation-delay: 0.2s;
}
.sk-cube-grid .sk-cube6 {
-webkit-animation-delay: 0.3s;
animation-delay: 0.3s;
}
.sk-cube-grid .sk-cube7 {
-webkit-animation-delay: 0s;
animation-delay: 0s;
}
.sk-cube-grid .sk-cube8 {
-webkit-animation-delay: 0.1s;
animation-delay: 0.1s;
}
.sk-cube-grid .sk-cube9 {
-webkit-animation-delay: 0.2s;
animation-delay: 0.2s;
}
@-webkit-keyframes sk-cubeGridScaleDelay {
0%, 35% {
background-color: #36393e;
}
0%, 70%, 100% {
-webkit-transform: scale3D(1, 1, 1);
transform: scale3D(1, 1, 1);
}
35% {
-webkit-transform: scale3D(0, 0, 1);
transform: scale3D(0, 0, 1);
}
100% {
background-color: #3d90ce;
}
}
}
</style>
@@ -0,0 +1,124 @@
<template>
<div class="content">
<ClosePageButton />
<div class="container-fluid lens-workspaces">
<div class="header sticky-top">
<h2><i class="material-icons">layers</i> Workspaces</h2>
</div>
<div class="row">
<div class="col-3" />
<div class="col-6">
<h2>Edit Workspace</h2>
<b-form @submit.prevent="updateWorkspace">
<b-form-group
label="Name:"
label-for="input-name"
>
<b-form-input
id="input-name"
v-model="workspace.name"
trim
/>
</b-form-group>
<b-form-group
label="Description:"
label-for="input-description"
>
<b-form-input
id="input-description"
v-model="workspace.description"
trim
/>
</b-form-group>
<b-form-row>
<b-col>
<b-button variant="primary" type="submit">
Save
</b-button>
<b-button v-b-modal.bv-modal-confirm>
Delete
</b-button>
<b-modal id="bv-modal-confirm" @ok="deleteWorkspace" ok-title="Delete" ok-variant="danger" title-class="confirm-header" hide-backdrop title="Confirm workspace delete">
<p>Are you sure you want to delete <strong>{{ workspace.name }}</strong> workspace from Lens?</p>
</b-modal>
</b-col>
</b-form-row>
</b-form>
</div>
<div class="col-3" />
</div>
</div>
</div>
</template>
<script>
import ClosePageButton from "@/components/common/ClosePageButton";
export default {
name: 'EditWorkspacePage',
components: {
ClosePageButton
},
data() {
return {
errors: {
name: null,
description: null
}
}
},
computed: {
workspace: function() {
return this.$store.getters.workspaceById(this.$route.params.id)
}
},
methods: {
updateWorkspace: function() {
this.$store.commit("updateWorkspace", this.workspace)
this.$router.push({
name: "workspaces-page"
})
},
deleteWorkspace: async function() {
await this.$store.commit("removeWorkspace", this.workspace)
this.$router.push({
name: "workspaces-page"
})
}
},
mounted: function() {
this.$store.commit("hideMenu");
},
destroyed: function() {
this.$store.commit("showMenu");
}
}
</script>
<style lang="scss" scoped>
#app > .main-view > .content {
left: 70px;
right: 70px;
}
h2 {
i {
position: relative;
top: 6px;
}
}
.lens-workspaces {
height: 100%;
overflow-y: scroll;
& input {
background-color: #252729 !important;
border: 0px !important;
color: #87909c !important;
}
.header {
padding-top: 15px;
}
}
</style>
+71
View File
@@ -0,0 +1,71 @@
<template>
<div class="content">
<div class="landing-page">
<b-container fluid class="h-100">
<!-- wrapper for center/center align -->
<b-row class="h-100">
<b-col sm="12" class="my-auto">
<div>
<b-row align-h="center" class="banner">
<b-col sm="8">
<h1 v-if="user && clusters.length === 0" class="display-4 text-center">
Welcome, {{ greetingName }}!
</h1>
<p v-if="clusters.length === 0" class="text-center">
Get started by associating one or more clusters to Kontena Lens.
</p>
</b-col>
</b-row>
</div>
</b-col>
</b-row>
</b-container>
</div>
</div>
</template>
<script>
export default {
name: 'LandingPage',
components: {},
methods: {},
computed: {
workspaces: function() {
return this.$store.getters.workspaces;
},
clusters: function() {
return this.$store.getters.clusters;
},
user: function() {
return this.$store.getters.user;
},
greetingName: function() {
return (this.user['first-name'] && this.user['first-name'] !== "") ? this.user['first-name'] : this.user.username
}
}
}
</script>
<style scoped lang="scss">
.landing-page{
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: #282b2f url(../assets/img/crane.svg) no-repeat;
background-position: 0px 35%;
background-size: 85%;
background-clip: content-box;
.banner {
h1 {
text-align: center;
font-size: 28px;
font-weight: normal;
}
p {
font-size: 14px;
}
}
}
</style>
@@ -0,0 +1,72 @@
<template>
<span>
<a
id="add-cluster"
class="add-cluster menu-item"
:class="[{
'active':isActive(),
}]"
@click.prevent="addCluster"
v-b-tooltip.hover.right
title="Add clusters"
>
<i class="material-icons">add_box</i>
<span v-if="newContexts.length > 0" class="badge badge-success">{{ newContexts.length }}</span>
</a>
</span>
</template>
<script>
import ClustersMixin from "@/mixins/ClustersMixin";
import { newContexts } from '@kubernetes/client-node/dist/config_types';
export default {
name: "AddClusterMenuItem",
mixins: [ClustersMixin],
data(){
return {
}
},
computed: {
},
methods: {
isActive: function( id ){
return "new" === this.$route.params.id;
},
addCluster: async function(){
this.$router.push({
name: "add-cluster-page",
params: {
id: "new"
},
}).catch(err => {})
}
},
mounted() {
console.log(this.newContexts.length)
this.$store.dispatch("reloadAvailableKubeContexts");
}
}
</script>
<style scoped lang="scss">
.add-cluster{
position: relative;
display: block;
color: #fff;
padding: 6px 8px 6px 9px;
opacity: 0.4;
cursor: pointer;
&.active{
opacity: 0.75;
}
i {
font-size: 50px;
}
.badge {
position: absolute;
bottom: 10px;
right: 10px;
}
}
</style>
@@ -0,0 +1,166 @@
<template>
<a
class="menu-item"
:class="[{
'active':isActive,
'online':(activated && !isActive),
}]"
@click.prevent="openClusterPage()"
@contextmenu.prevent="openContextMenu()"
:title="preferences.clusterName"
v-b-tooltip.hover.right
>
<div
class="icon-frame"
:class="[{
'active':isActive
}]"
>
<img v-if="preferences.icon" :src="preferences.icon">
<hashicon v-else :name="preferences.clusterName" size="38" />
<span v-if="isAdmin && eventCount > 0" class="badge badge-danger">{{ eventCount >= 1000 ? "1000+" : eventCount }}</span>
</div>
</a>
</template>
<script>
import hashicon from "../hashicon/hashicon";
import { setInterval, clearInterval } from "timers";
const { remote } = require('electron')
const { Menu, MenuItem } = remote
export default {
name: "ClusterMenuItem",
components: {
hashicon
},
data(){
return {
eventPoller: null,
eventCount: 0,
activated: false
}
},
props: {
cluster: {
type: Object,
required: true,
default: null
},
},
computed: {
preferences: function() {
return this.cluster.preferences;
},
clusterAccessible: function() {
return this.$store.getters.clusterById(this.cluster.id).accessible;
},
isActive: function() {
return this.cluster.id === this.$route.params.id;
},
isAdmin: function() {
return this.cluster.isAdmin;
}
},
methods: {
openClusterPage() {
this.activated = true;
this.$router.push({
name: "cluster-page",
params: {
id: this.cluster.id
},
}).catch(err => {})
},
openContextMenu(){
const self = this;
const menu = new Menu()
menu.append(new MenuItem({ label: 'Settings', click() {
self.$router.push({
name: "cluster-settings-page",
params: {
id: self.cluster.id
},
}).catch(err => {})
} }))
if (this.activated) {
menu.append(new MenuItem({ label: 'Disconnect', click() {
self.activated = false;
self.stopPolling();
self.$store.dispatch("stopCluster", self.cluster.id);
if (self.isActive) {
self.$router.push({
name: "landing-page"
}).catch(err => {})
}
} }))
}
menu.popup({ window: remote.getCurrentWindow() })
},
toggleEventPolling: function() {
if(this.clusterAccessible && !this.eventPoller && this.activated && !this.isActive) {
this.fetchEvents()
this.eventPoller = setInterval(async () => {
await this.fetchEvents()
}, 30 * 1000);
} else {
this.eventCount = 0;
this.stopPolling()
}
},
fetchEvents: async function() {
try {
this.eventCount = await this.$promiseIpc.send("getClusterEvents", this.cluster.id);
} catch (error) {
console.error("Failed to get event count for cluster:", error)
}
},
stopPolling: function() {
if (this.eventPoller) {
clearInterval(this.eventPoller)
this.eventPoller = null
}
}
},
watch: {
clusterAccessible: "toggleEventPolling",
isActive: "toggleEventPolling"
},
beforeDestroy() {
this.stopPolling();
}
}
</script>
<style scoped lang="scss">
.badge {
position: absolute;
bottom: 10px;
right: 10px;
padding: 4px;
i {
font-size: 8px;
}
}
img {
width: 38px;
max-height: 42px;
}
span.hashicon {
margin-top: 6px;
}
div.icon-frame {
width: 44px;
height: 44px;
display: flex;
align-items: center;
&.active, &:hover {
margin-left: -3px;
padding: 0px 0px 0px 3px;
background-color: #f8f9fa;
border-radius: 3px;
}
}
</style>
@@ -0,0 +1,100 @@
<template>
<div class="main-menu">
<draggable v-model="clusters">
<ClusterMenuItem
v-for="cluster in clusters" :key="cluster.id"
:cluster="cluster"
/>
</draggable>
<AddClusterMenuItem />
<b-tooltip v-if="clusters.length === 0" show target="add-cluster" placement="rightbottom">
<p class="text-left">
This is the quick launch menu.
</p>
<p class="text-left">
Associate clusters and choose the ones you want to access via quick launch menu by clicking the + button.
</p>
</b-tooltip>
</div>
</template>
<script>
import ClusterMenuItem from "@/components/MainMenu/ClusterMenuItem";
import AddClusterMenuItem from "@/components/MainMenu/AddClusterMenuItem";
import draggable from 'vuedraggable'
import { clusterStore } from "@/../common/cluster-store";
const { remote } = require('electron')
const { Menu, MenuItem } = remote
export default {
name: "MainMenu",
components: {
ClusterMenuItem,
AddClusterMenuItem,
draggable
},
data(){
return {
}
},
computed : {
menuAvailable: function(){
return this.$store.getters.isLoggedIn && this.$store.getters.isEulaAccepted
},
clusters: {
get: function() {
return this.$store.getters.clusters;
},
set: function(clusters) {
this.$store.commit("updateClusters", clusters);
clusterStore.storeClusters(clusters);
}
}
},
methods: {
isActive: function( id ){
return id === this.$route.params.id;
},
addCluster: async function(){
this.$router.push({
name: "add-cluster-page",
params: {
id: "new"
},
}).catch(err => {})
}
}
}
</script>
<style scoped lang="scss">
.main-menu {
position: absolute;
left: 0;
top: 0;
bottom: 20px;
width: 70px;
padding-top: 25px;
background: #252729;
overflow: hidden;
z-index: 1000;
}
.menu-item {
position: relative;
cursor: pointer;
display: block;
padding: 6px 15px 6px 15px;
color: #87909c;
opacity: 0.3;
&.active {
opacity: 1;
}
&.online {
opacity: 1;
}
&:hover {
opacity: 1;
}
}
</style>
+289
View File
@@ -0,0 +1,289 @@
<template>
<div class="content">
<ClosePageButton />
<div class="container-fluid lens-preferences">
<div class="header sticky-top">
<h2>Preferences</h2>
</div>
<!-- <div class="row">
<div class="col-3" />
<div ref="content" class="global-settings col-6">
<h2>Kontena Lens PRO License</h2>
<b-form-group
label="No Kontena Lens PRO license assigned. Assign license by entering license key below:"
label-for="input-license-key"
v-if="!licenceData || licenceData.status !== 'valid'"
>
<b-form-input
id="input-license-key"
v-model="licenseKey"
trim
@blur="onLicenseKeySave"
placeholder="Enter license key"
:disabled="!isLoggedIn"
:state="errors.licenseKey"
/>
</b-form-group>
<div v-if="licenceData">
<p v-if="licenceData.status === 'valid'">
Valid Kontena Lens PRO license assigned. Valid until {{ licenceData.valid_until }}<span v-if="licenceData.recurring">(renews automatically)</span>.
</p>
</div>
</div>
<div class="col-3" />
</div> -->
<div class="row">
<div class="col-3" />
<div ref="content" class="global-settings col-6">
<h2>Color Theme</h2>
<b-form-group
label="Cluster Color theme:"
label-for="input-color-theme"
>
<b-form-select
id="input-color-theme"
v-model="preferences.colorTheme"
@change="onSave"
:options="colorThemeOptions"
/>
</b-form-group>
</div>
<div class="col-3" />
</div>
<div class="row">
<div class="col-3" />
<div ref="content" class="global-settings col-6">
<h2>Helm</h2>
<b-form-group
label="Repositories:"
label-for="textarea-helm-repositories"
>
<b-input-group>
<b-form-select
:options="hubRepositories"
v-model="selectedHubRepo"
>
<template v-slot:first>
<option value="">
Select Helm repository
</option>
</template>
</b-form-select>
<b-input-group-append>
<b-button :disabled="!selectedHubRepo" @click="addHubRepo" variant="primary">
Add <b-spinner small v-if="isHelmProcessing" label="Small Spinner" />
</b-button>
</b-input-group-append>
</b-input-group>
</b-form-group>
<b-list-group>
<b-list-group-item v-for="repo in helmRepos" :key="repo.name">
<span v-b-tooltip.hover v-b-tooltip.right :title="repo.url">{{ repo.name }}</span>
<button @click="removeRepo(repo)" :data-repo="repo.name" class="close">
<i class="material-icons">delete</i>
</button>
</b-list-group-item>
</b-list-group>
</div>
<div class="col-3" />
</div>
<div class="row">
<div class="col-3" />
<div ref="content" class="global-settings col-6">
<h2>HTTP Proxy</h2>
<b-form-group
label="Proxy URL:"
label-for="input-https-proxy"
description="Proxy is used only for non-cluster communication."
>
<b-form-input
id="input-https-proxy"
v-model="preferences.httpsProxy"
trim
@blur="onSave"
placeholder="Type HTTP proxy url (example: http://proxy.acme.org:8080)"
/>
</b-form-group>
<h2>Certificate Trust</h2>
<b-form-group
label-for="checkbox-allow-untrusted-cas"
description="This will make Lens to trust ANY certificate authority without any validations. Needed with some corporate proxies that do certificate re-writing. Does not affect cluster communications!"
>
<b-form-checkbox
id="checkbox-allow-untrusted-cas"
switch
v-model="preferences.allowUntrustedCAs"
@input="onSave"
>
Allow untrusted Certificate Authorities
</b-form-checkbox>
</b-form-group>
<h2>Telemetry & Usage Tracking</h2>
<b-form-group
label-for="checkbox-allow-telemetry"
description="Telemetry & usage data is collected to continuously improve the Kontena Lens experience."
>
<b-form-checkbox
id="checkbox-allow-telemetry"
switch
v-model="preferences.allowTelemetry"
:disabled="licenceData && licenceData.status === 'valid'"
@input="onSave"
>
Allow telemetry & usage tracking
</b-form-checkbox>
</b-form-group>
</div>
<div class="col-3" />
</div>
</div>
</div>
</template>
<script>
import * as request from 'request-promise-native'
import { globalRequestOpts } from "../../common/request"
import ClosePageButton from "@/components/common/ClosePageButton";
export default {
name: 'PreferencesPage',
components: {
ClosePageButton
},
data(){
return {
licenseKey: "",
hubRepositories: [],
selectedHubRepo: "",
colorThemeOptions: [
{ value: "dark", text: "Dark" },
{ value: "light", text: "Light" },
],
errors: {
licenseKey: null
},
isHelmProcessing: null,
}
},
computed: {
helmRepos: function() {
return this.$store.getters.repos;
},
preferences: function() {
return this.$store.getters.preferences;
},
licenceData: function() {
return this.$store.getters.licenseData;
},
isLoggedIn: function() {
console.log(this.$store.getters.isLoggedIn)
return this.$store.getters.isLoggedIn;
}
},
methods: {
onSave: function() {
this.$store.commit("savePreferences", this.preferences);
},
onLicenseKeySave: async function() {
if (this.licenseKey !== "") {
try {
await this.$store.dispatch("exhangeKey", {
key: this.licenseKey,
user: this.$store.getters.user
})
this.errors.licenseKey = true;
} catch(error) {
this.errors.licenseKey = false;
}
} else {
this.errors.licenseKey = null;
}
},
request: async function(opts) {
const o = Object.assign({
timeout: 10000,
headers: {},
resolveWithFullResponse: true,
json: true
}, globalRequestOpts(opts))
return await request(o)
},
repoCompare: function( a, b ) {
if ( a.name < b.name ){
return -1;
}
if ( a.name > b.name ){
return 1;
}
return 0;
},
addHubRepo: async function() {
if(!this.selectedHubRepo || this.selectedHubRepo === "") {
return
}
this.isHelmProcessing = true
const [name, url ] = this.selectedHubRepo.split("|")
await this.$store.dispatch('addHelmRepo', { name, url })
this.selectedHubRepo = ""
this.isHelmProcessing = false
},
removeRepo: async function(repo) {
await this.$store.dispatch('removeHelmRepo', repo)
},
loadHubRepositories: async function() {
const res = await this.request({ uri: "https://hub.helm.sh/assets/js/repos.json"}).catch((error) => { this.hubRepositories = [] })
this.hubRepositories = res.body.data.sort(this.repoCompare).map((repo) => {
return { text: repo.name, value: repo.name+"|"+repo.url}
})
}
},
created() {
this.$store.dispatch('refreshHelmRepos')
},
mounted: async function() {
this.$store.commit("hideMenu");
this.loadHubRepositories()
},
destroyed: function() {
this.$store.commit("showMenu");
}
}
</script>
<style lang="scss" scoped>
#app > .main-view > .content {
left: 70px;
right: 70px;
}
.lens-preferences {
height: 100%;
overflow-y: scroll;
& input {
background-color: #252729 !important;
border: 0px !important;
color: #87909c !important;
}
.header {
padding-top: 15px;
}
.close {
text-shadow: none;
}
.list-group-item {
background-color: #252729 !important;
color: #87909c !important;
}
& i {
color: #87909c !important;
}
}
.global-settings {
padding-top: 0.75em;
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<template>
<div class="content">
<div class="container-fluid whats-new">
<div class="row">
<div class="col-md whats-new-text-wrapper">
<div class="logo">
<img src="../assets/img/lens-logo.svg">
</div>
<!-- Safe to use v-html as we self generate the html locally only -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="whats-new-text" v-html="content">
<!-- What's new content -->
</div>
</div>
</div>
<div class="row">
<div class="col-md whats-new-actions-wrapper">
<div class="whats-new-actions">
<button type="button" class="btn btn-primary" @click="toLanding">
Ok, got it!
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import marked from 'marked'
import fs from 'fs'
import * as path from "path"
export default {
name: 'WhatsNewPage',
data() {
return {
error: "",
// eslint-disable-next-line
content: marked(fs.readFileSync(path.join(__static, "RELEASE_NOTES.md"), 'utf8')),
}
},
components: {},
computed: {
},
methods: {
toLanding: async function() {
await this.$store.dispatch("updateLastSeenAppVersion")
this.$router.push({
name: "landing-page",
}).catch(err => {})
},
},
}
</script>
<style lang="scss" scoped>
.content {
background: #282b2f url(../assets/img/crane.svg) no-repeat;
background-position: 0px 35%;
background-size: 85%;
background-clip: content-box;
}
.content > .whats-new{
width: 100%;
height: 100%;
background-color:rgba(0, 0, 0, 0.3);
.whats-new-text-wrapper{
position: absolute;
top: 20px;
bottom: 77px;
left: 0;
right: 0;
overflow-y: scroll;
.logo {
padding: 30px 30px;
max-width: 1000px;
img {
width: 200px;
}
}
.whats-new-text{
padding: 10px 30px;
max-width: 1000px;
h1{
font-size: 24px;
}
h2{
padding: 0;
}
}
}
.whats-new-actions-wrapper{
position: absolute;
height: 77px;
left: 0;
right: 0;
bottom: 0;
border-top: 1px solid #333;
background: var(--lens-pane-bg);
.whats-new-actions{
padding: 20px;
text-align: center;
}
}
}
</style>
Loaded 100 of 797 files, more files were not shown because too many files have changed in this diff. Show more