mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Lens app source code (#119)
Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
1 parent
936cbd53d4
commit
1d0815abd2
797 files changed
+73714
-215
No files matched your search
Whitespace-only changes.
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: lens-metrics
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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}}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
});
|
||||
};
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import * as util from "util"
|
||||
import { exec } from "child_process";
|
||||
|
||||
export const promiseExec = util.promisify(exec)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Tracker } from "../common/tracker"
|
||||
import { app, remote } from "electron"
|
||||
|
||||
export const tracker = new Tracker(app || remote.app);
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
@import "~typeface-roboto/index.css";
|
||||
@import "~material-design-icons/iconfont/material-icons.css";
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in new issue
Block a user