mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
convert the cluster settings view from Vue to React (#613)
- Add a basic file picker component - Now stores the icons as base64 img src formatted data blobs - Changed over cluster settings to mobx - Removed old Vue files Signed-off-by: Sebastian Malton <smalton@mirantis.com> Co-authored-by: Sebastian Malton <smalton@mirantis.com>
This commit is contained in:
parent
bac6fbaaf1
commit
d4ff99f3bf
@ -17,4 +17,34 @@ export const clusterIpc = {
|
|||||||
return clusterStore.getById(clusterId)?.disconnect();
|
return clusterStore.getById(clusterId)?.disconnect();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
installFeature: createIpcChannel({
|
||||||
|
channel: "cluster:install-feature",
|
||||||
|
handle: async (clusterId: ClusterId, feature: string, config?: any) => {
|
||||||
|
tracker.event("cluster", "install", feature);
|
||||||
|
const cluster = clusterStore.getById(clusterId);
|
||||||
|
|
||||||
|
if (cluster) {
|
||||||
|
await cluster.installFeature(feature, config)
|
||||||
|
} else {
|
||||||
|
throw `${clusterId} is not a valid cluster id`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
uninstallFeature: createIpcChannel({
|
||||||
|
channel: "cluster:uninstall-feature",
|
||||||
|
handle: (clusterId: ClusterId, feature: string) => {
|
||||||
|
tracker.event("cluster", "uninstall", feature);
|
||||||
|
return clusterStore.getById(clusterId)?.uninstallFeature(feature)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
upgradeFeature: createIpcChannel({
|
||||||
|
channel: "cluster:upgrade-feature",
|
||||||
|
handle: (clusterId: ClusterId, feature: string, config?: any) => {
|
||||||
|
tracker.event("cluster", "upgrade", feature);
|
||||||
|
return clusterStore.getById(clusterId)?.upgradeFeature(feature, config)
|
||||||
|
}
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import type { WorkspaceId } from "./workspace-store";
|
import type { WorkspaceId } from "./workspace-store";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import filenamify from "filenamify";
|
import filenamify from "filenamify";
|
||||||
import { app, ipcRenderer } from "electron";
|
import { app, ipcRenderer, remote } from "electron";
|
||||||
import { copyFile, ensureDir, unlink } from "fs-extra";
|
import { copyFile, ensureDir, unlink } from "fs-extra";
|
||||||
import { action, computed, observable, toJS } from "mobx";
|
import { action, computed, observable, toJS } from "mobx";
|
||||||
import { appProto, noClustersHost } from "./vars";
|
import { appProto, noClustersHost } from "./vars";
|
||||||
@ -53,7 +53,8 @@ export interface ClusterPreferences {
|
|||||||
|
|
||||||
export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
||||||
static get iconsDir() {
|
static get iconsDir() {
|
||||||
return path.join(app.getPath("userData"), "icons");
|
// TODO: remove remote cheat
|
||||||
|
return path.join((app || remote.app).getPath("userData"), "icons");
|
||||||
}
|
}
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
@ -130,30 +131,6 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
|
||||||
protected async uploadIcon({ clusterId, ...upload }: ClusterIconUpload): Promise<string> {
|
|
||||||
const cluster = this.getById(clusterId);
|
|
||||||
if (cluster) {
|
|
||||||
tracker.event("cluster", "upload-icon");
|
|
||||||
const fileDest = path.join(ClusterStore.iconsDir, filenamify(cluster.contextName + "-" + upload.name))
|
|
||||||
await ensureDir(path.dirname(fileDest));
|
|
||||||
await copyFile(upload.path, fileDest)
|
|
||||||
cluster.preferences.icon = `${appProto}:///icons/${fileDest}`
|
|
||||||
return cluster.preferences.icon;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
protected resetIcon(clusterId: ClusterId) {
|
|
||||||
const cluster = this.getById(clusterId);
|
|
||||||
if (cluster) {
|
|
||||||
tracker.event("cluster", "reset-icon")
|
|
||||||
const iconPath = path.join(ClusterStore.iconsDir, path.basename(cluster.preferences.icon));
|
|
||||||
unlink(iconPath).catch(() => null); // remove file
|
|
||||||
delete cluster.preferences.icon;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
protected fromStore({ activeCluster, clusters = [] }: ClusterStoreModel = {}) {
|
protected fromStore({ activeCluster, clusters = [] }: ClusterStoreModel = {}) {
|
||||||
const currentClusters = this.clusters.toJS();
|
const currentClusters = this.clusters.toJS();
|
||||||
|
|||||||
@ -45,6 +45,8 @@ export async function invokeIpc<R = any>(channel: IpcChannel, ...args: any[]): P
|
|||||||
// todo: make isomorphic api
|
// todo: make isomorphic api
|
||||||
export function handleIpc(channel: IpcChannel, handler: IpcMessageHandler, options: IpcHandleOpts = {}) {
|
export function handleIpc(channel: IpcChannel, handler: IpcMessageHandler, options: IpcHandleOpts = {}) {
|
||||||
const { timeout = 0 } = options;
|
const { timeout = 0 } = options;
|
||||||
|
logger.info(`[IPC]: setup to handle "${channel}"`);
|
||||||
|
|
||||||
ipcMain.handle(channel, async (event, ...args) => {
|
ipcMain.handle(channel, async (event, ...args) => {
|
||||||
logger.info(`[IPC]: handle "${channel}"`, { args });
|
logger.info(`[IPC]: handle "${channel}"`, { args });
|
||||||
return new Promise(async (resolve, reject) => {
|
return new Promise(async (resolve, reject) => {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import path from "path"
|
|||||||
import os from "os"
|
import os from "os"
|
||||||
import yaml from "js-yaml"
|
import yaml from "js-yaml"
|
||||||
import logger from "../main/logger";
|
import logger from "../main/logger";
|
||||||
|
import fse from "fs-extra"
|
||||||
|
|
||||||
function resolveTilde(filePath: string) {
|
function resolveTilde(filePath: string) {
|
||||||
if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) {
|
if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) {
|
||||||
@ -15,11 +16,13 @@ function resolveTilde(filePath: string) {
|
|||||||
|
|
||||||
export function loadConfig(pathOrContent?: string): KubeConfig {
|
export function loadConfig(pathOrContent?: string): KubeConfig {
|
||||||
const kc = new KubeConfig();
|
const kc = new KubeConfig();
|
||||||
if (path.isAbsolute(pathOrContent)) {
|
|
||||||
kc.loadFromFile(resolveTilde(pathOrContent));
|
if (fse.pathExistsSync(pathOrContent)) {
|
||||||
|
kc.loadFromFile(path.resolve(resolveTilde(pathOrContent)));
|
||||||
} else {
|
} else {
|
||||||
kc.loadFromString(pathOrContent);
|
kc.loadFromString(pathOrContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
return kc
|
return kc
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,7 +157,12 @@ export function saveConfigToAppFiles(clusterId: string, kubeConfig: KubeConfig |
|
|||||||
export async function getKubeConfigLocal(): Promise<string> {
|
export async function getKubeConfigLocal(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const configFile = path.join(process.env.HOME, '.kube', 'config');
|
const configFile = path.join(process.env.HOME, '.kube', 'config');
|
||||||
return readFile(configFile, "utf8");
|
const file = await readFile(configFile, "utf8");
|
||||||
|
const obj = yaml.safeLoad(file);
|
||||||
|
if (obj.contexts) {
|
||||||
|
obj.contexts = obj.context.filter((ctx: any) => ctx?.context?.cluster && ctx?.name)
|
||||||
|
}
|
||||||
|
return yaml.safeDump(obj);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug(`Cannot read local kube-config: ${err}`)
|
logger.debug(`Cannot read local kube-config: ${err}`)
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
@ -71,11 +71,11 @@ export class UserStore extends BaseStore<UserStoreModel> {
|
|||||||
if (kubeConfig) {
|
if (kubeConfig) {
|
||||||
this.newContexts.clear();
|
this.newContexts.clear();
|
||||||
const localContexts = loadConfig(kubeConfig).getContexts();
|
const localContexts = loadConfig(kubeConfig).getContexts();
|
||||||
localContexts.forEach(({ name }) => {
|
console.log(localContexts)
|
||||||
if (!this.seenContexts.has(name)) {
|
localContexts
|
||||||
this.newContexts.add(name);
|
.filter(ctx => ctx.cluster)
|
||||||
}
|
.filter(ctx => !this.seenContexts.has(ctx.name))
|
||||||
})
|
.forEach(ctx => this.newContexts.add(ctx.name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,7 +27,8 @@ export interface MetricsConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MetricsFeature extends Feature {
|
export class MetricsFeature extends Feature {
|
||||||
name = 'metrics';
|
static id = 'metrics'
|
||||||
|
name = MetricsFeature.id;
|
||||||
latestVersion = "v2.17.2-lens1"
|
latestVersion = "v2.17.2-lens1"
|
||||||
|
|
||||||
config: MetricsConfiguration = {
|
config: MetricsConfiguration = {
|
||||||
@ -51,58 +52,49 @@ export class MetricsFeature extends Feature {
|
|||||||
storageClass: null,
|
storageClass: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
async install(cluster: Cluster): Promise<boolean> {
|
async install(cluster: Cluster): Promise<void> {
|
||||||
// Check if there are storageclasses
|
// Check if there are storageclasses
|
||||||
const storageClient = cluster.getProxyKubeconfig().makeApiClient(k8s.StorageV1Api)
|
const storageClient = cluster.getProxyKubeconfig().makeApiClient(k8s.StorageV1Api)
|
||||||
const scs = await storageClient.listStorageClass();
|
const scs = await storageClient.listStorageClass();
|
||||||
scs.body.items.forEach(sc => {
|
|
||||||
if(sc.metadata.annotations &&
|
this.config.persistence.enabled = scs.body.items.some(sc => (
|
||||||
(sc.metadata.annotations['storageclass.kubernetes.io/is-default-class'] === 'true' || sc.metadata.annotations['storageclass.beta.kubernetes.io/is-default-class'] === 'true')) {
|
sc.metadata?.annotations?.['storageclass.kubernetes.io/is-default-class'] === 'true' ||
|
||||||
this.config.persistence.enabled = true;
|
sc.metadata?.annotations?.['storageclass.beta.kubernetes.io/is-default-class'] === 'true'
|
||||||
}
|
));
|
||||||
});
|
|
||||||
|
|
||||||
return super.install(cluster)
|
return super.install(cluster)
|
||||||
}
|
}
|
||||||
|
|
||||||
async upgrade(cluster: Cluster): Promise<boolean> {
|
async upgrade(cluster: Cluster): Promise<void> {
|
||||||
return this.install(cluster)
|
return this.install(cluster)
|
||||||
}
|
}
|
||||||
|
|
||||||
async featureStatus(kc: KubeConfig): Promise<FeatureStatus> {
|
async featureStatus(kc: KubeConfig): Promise<FeatureStatus> {
|
||||||
return new Promise<FeatureStatus>( async (resolve, reject) => {
|
const client = kc.makeApiClient(AppsV1Api)
|
||||||
const client = kc.makeApiClient(AppsV1Api)
|
const status: FeatureStatus = {
|
||||||
const status: FeatureStatus = {
|
currentVersion: null,
|
||||||
currentVersion: null,
|
installed: false,
|
||||||
installed: false,
|
latestVersion: this.latestVersion,
|
||||||
latestVersion: this.latestVersion,
|
canUpgrade: false, // Dunno yet
|
||||||
canUpgrade: false, // Dunno yet
|
};
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
|
const prometheus = (await client.readNamespacedStatefulSet('prometheus', 'lens-metrics')).body;
|
||||||
const prometheus = (await client.readNamespacedStatefulSet('prometheus', 'lens-metrics')).body;
|
status.installed = true;
|
||||||
status.installed = true;
|
status.currentVersion = prometheus.spec.template.spec.containers[0].image.split(":")[1];
|
||||||
status.currentVersion = prometheus.spec.template.spec.containers[0].image.split(":")[1];
|
status.canUpgrade = semver.lt(status.currentVersion, this.latestVersion, true);
|
||||||
status.canUpgrade = semver.lt(status.currentVersion, this.latestVersion, true);
|
} catch {
|
||||||
resolve(status)
|
// ignore error
|
||||||
} catch(error) {
|
}
|
||||||
resolve(status)
|
|
||||||
}
|
return status;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async uninstall(cluster: Cluster): Promise<boolean> {
|
async uninstall(cluster: Cluster): Promise<void> {
|
||||||
return new Promise<boolean>(async (resolve, reject) => {
|
const rbacClient = cluster.getProxyKubeconfig().makeApiClient(RbacAuthorizationV1Api)
|
||||||
const rbacClient = cluster.getProxyKubeconfig().makeApiClient(RbacAuthorizationV1Api)
|
|
||||||
try {
|
await this.deleteNamespace(cluster.getProxyKubeconfig(), "lens-metrics")
|
||||||
await this.deleteNamespace(cluster.getProxyKubeconfig(), "lens-metrics")
|
await rbacClient.deleteClusterRole("lens-prometheus");
|
||||||
await rbacClient.deleteClusterRole("lens-prometheus");
|
await rbacClient.deleteClusterRoleBinding("lens-prometheus");
|
||||||
await rbacClient.deleteClusterRoleBinding("lens-prometheus");
|
|
||||||
resolve(true);
|
|
||||||
} catch(error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,48 +3,42 @@ import {KubeConfig, RbacAuthorizationV1Api} from "@kubernetes/client-node"
|
|||||||
import { Cluster } from "../main/cluster"
|
import { Cluster } from "../main/cluster"
|
||||||
|
|
||||||
export class UserModeFeature extends Feature {
|
export class UserModeFeature extends Feature {
|
||||||
name = 'user-mode';
|
static id = 'user-mode'
|
||||||
|
name = UserModeFeature.id;
|
||||||
latestVersion = "v2.0.0"
|
latestVersion = "v2.0.0"
|
||||||
|
|
||||||
async install(cluster: Cluster): Promise<boolean> {
|
async install(cluster: Cluster): Promise<void> {
|
||||||
return super.install(cluster)
|
return super.install(cluster)
|
||||||
}
|
}
|
||||||
|
|
||||||
async upgrade(cluster: Cluster): Promise<boolean> {
|
async upgrade(cluster: Cluster): Promise<void> {
|
||||||
return true
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
async featureStatus(kc: KubeConfig): Promise<FeatureStatus> {
|
async featureStatus(kc: KubeConfig): Promise<FeatureStatus> {
|
||||||
return new Promise<FeatureStatus>( async (resolve, reject) => {
|
const client = kc.makeApiClient(RbacAuthorizationV1Api)
|
||||||
const client = kc.makeApiClient(RbacAuthorizationV1Api)
|
const status: FeatureStatus = {
|
||||||
const status: FeatureStatus = {
|
currentVersion: null,
|
||||||
currentVersion: null,
|
installed: false,
|
||||||
installed: false,
|
latestVersion: this.latestVersion,
|
||||||
latestVersion: this.latestVersion,
|
canUpgrade: false, // Dunno yet
|
||||||
canUpgrade: false, // Dunno yet
|
};
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
await client.readClusterRoleBinding("lens-user")
|
await client.readClusterRoleBinding("lens-user")
|
||||||
status.installed = true;
|
status.installed = true;
|
||||||
status.currentVersion = this.latestVersion
|
status.currentVersion = this.latestVersion;
|
||||||
status.canUpgrade = false
|
status.canUpgrade = false;
|
||||||
resolve(status)
|
} catch {
|
||||||
} catch(error) {
|
// ignore error
|
||||||
resolve(status)
|
}
|
||||||
}
|
|
||||||
});
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
async uninstall(cluster: Cluster): Promise<boolean> {
|
async uninstall(cluster: Cluster): Promise<void> {
|
||||||
return new Promise<boolean>(async (resolve, reject) => {
|
const rbacClient = cluster.getProxyKubeconfig().makeApiClient(RbacAuthorizationV1Api)
|
||||||
const rbacClient = cluster.getProxyKubeconfig().makeApiClient(RbacAuthorizationV1Api)
|
await rbacClient.deleteClusterRole("lens-user");
|
||||||
try {
|
await rbacClient.deleteClusterRoleBinding("lens-user");
|
||||||
await rbacClient.deleteClusterRole("lens-user");
|
|
||||||
await rbacClient.deleteClusterRoleBinding("lens-user");
|
|
||||||
resolve(true);
|
|
||||||
} catch(error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,6 +34,9 @@ export class ClusterManager {
|
|||||||
// listen for ipc-events that must/can be handled *only* in main-process (nodeIntegration=true)
|
// listen for ipc-events that must/can be handled *only* in main-process (nodeIntegration=true)
|
||||||
clusterIpc.activate.handleInMain();
|
clusterIpc.activate.handleInMain();
|
||||||
clusterIpc.disconnect.handleInMain();
|
clusterIpc.disconnect.handleInMain();
|
||||||
|
clusterIpc.installFeature.handleInMain();
|
||||||
|
clusterIpc.uninstallFeature.handleInMain();
|
||||||
|
clusterIpc.upgradeFeature.handleInMain();
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
|
|||||||
@ -136,7 +136,7 @@ export class Cluster implements ClusterModel {
|
|||||||
|
|
||||||
async reconnect() {
|
async reconnect() {
|
||||||
logger.info(`[CLUSTER]: reconnect`, this.getMeta());
|
logger.info(`[CLUSTER]: reconnect`, this.getMeta());
|
||||||
await this.contextHandler.stopServer();
|
this.contextHandler.stopServer();
|
||||||
await this.contextHandler.ensureServer();
|
await this.contextHandler.ensureServer();
|
||||||
this.disconnected = false;
|
this.disconnected = false;
|
||||||
}
|
}
|
||||||
@ -206,15 +206,15 @@ export class Cluster implements ClusterModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async installFeature(name: string, config: any) {
|
async installFeature(name: string, config: any) {
|
||||||
return await installFeature(name, this, config)
|
return installFeature(name, this, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
async upgradeFeature(name: string, config: any) {
|
async upgradeFeature(name: string, config: any) {
|
||||||
return await upgradeFeature(name, this, config)
|
return upgradeFeature(name, this, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
async uninstallFeature(name: string) {
|
async uninstallFeature(name: string) {
|
||||||
return await uninstallFeature(name, this)
|
return uninstallFeature(name, this)
|
||||||
}
|
}
|
||||||
|
|
||||||
getPrometheusApiPrefix() {
|
getPrometheusApiPrefix() {
|
||||||
|
|||||||
@ -1,55 +1,44 @@
|
|||||||
import { KubeConfig } from "@kubernetes/client-node"
|
import { KubeConfig } from "@kubernetes/client-node"
|
||||||
import logger from "./logger";
|
import logger from "./logger";
|
||||||
import { Cluster } from "./cluster";
|
import { Cluster } from "./cluster";
|
||||||
import { Feature, FeatureStatusMap } from "./feature"
|
import { Feature, FeatureStatusMap, FeatureMap } from "./feature"
|
||||||
import { MetricsFeature } from "../features/metrics"
|
import { MetricsFeature } from "../features/metrics"
|
||||||
import { UserModeFeature } from "../features/user-mode"
|
import { UserModeFeature } from "../features/user-mode"
|
||||||
|
|
||||||
const ALL_FEATURES: any = {
|
const ALL_FEATURES: Map<string, Feature> = new Map([
|
||||||
'metrics': new MetricsFeature(null),
|
[MetricsFeature.id, new MetricsFeature(null)],
|
||||||
'user-mode': new UserModeFeature(null),
|
[UserModeFeature.id, new UserModeFeature(null)],
|
||||||
}
|
]);
|
||||||
|
|
||||||
export async function getFeatures(cluster: Cluster): Promise<FeatureStatusMap> {
|
export async function getFeatures(cluster: Cluster): Promise<FeatureStatusMap> {
|
||||||
return new Promise<FeatureStatusMap>(async (resolve, reject) => {
|
const result: FeatureStatusMap = {};
|
||||||
const result: FeatureStatusMap = {};
|
logger.debug(`features for ${cluster.contextName}`);
|
||||||
logger.debug(`features for ${cluster.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 kc = new KubeConfig()
|
|
||||||
kc.loadFromFile(cluster.getProxyKubeconfigPath())
|
|
||||||
|
|
||||||
const status = await feature.featureStatus(kc);
|
|
||||||
result[feature.name] = status
|
|
||||||
|
|
||||||
} else {
|
for (const [key, feature] of ALL_FEATURES) {
|
||||||
logger.error("ALL_FEATURES.hasOwnProperty(key) returned FALSE ?!?!?!?!")
|
logger.debug(`feature ${key}`);
|
||||||
|
logger.debug("getting feature status...");
|
||||||
|
|
||||||
}
|
const kc = new KubeConfig();
|
||||||
}
|
kc.loadFromFile(cluster.getProxyKubeconfigPath());
|
||||||
logger.debug(`getFeatures resolving with features: ${JSON.stringify(result)}`);
|
|
||||||
resolve(result);
|
result[feature.name] = await feature.featureStatus(kc);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
logger.debug(`getFeatures resolving with features: ${JSON.stringify(result)}`);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function installFeature(name: string, cluster: Cluster, config: any) {
|
export async function installFeature(name: string, cluster: Cluster, config: any): Promise<void> {
|
||||||
const feature = ALL_FEATURES[name] as Feature
|
|
||||||
// TODO Figure out how to handle config stuff
|
// TODO Figure out how to handle config stuff
|
||||||
await feature.install(cluster)
|
return ALL_FEATURES.get(name).install(cluster)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upgradeFeature(name: string, cluster: Cluster, config: any) {
|
export async function upgradeFeature(name: string, cluster: Cluster, config: any): Promise<void> {
|
||||||
const feature = ALL_FEATURES[name] as Feature
|
|
||||||
// TODO Figure out how to handle config stuff
|
// TODO Figure out how to handle config stuff
|
||||||
await feature.upgrade(cluster)
|
return ALL_FEATURES.get(name).upgrade(cluster)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uninstallFeature(name: string, cluster: Cluster) {
|
export async function uninstallFeature(name: string, cluster: Cluster): Promise<void> {
|
||||||
const feature = ALL_FEATURES[name] as Feature
|
return ALL_FEATURES.get(name).uninstall(cluster)
|
||||||
|
|
||||||
await feature.uninstall(cluster)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { Cluster } from "./cluster";
|
|||||||
import logger from "./logger";
|
import logger from "./logger";
|
||||||
|
|
||||||
export type FeatureStatusMap = Record<string, FeatureStatus>
|
export type FeatureStatusMap = Record<string, FeatureStatus>
|
||||||
|
export type FeatureMap = Record<string, Feature>
|
||||||
|
|
||||||
export interface FeatureInstallRequest {
|
export interface FeatureInstallRequest {
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
@ -25,23 +26,22 @@ export abstract class Feature {
|
|||||||
name: string;
|
name: string;
|
||||||
latestVersion: string;
|
latestVersion: string;
|
||||||
|
|
||||||
abstract async upgrade(cluster: Cluster): Promise<boolean>;
|
abstract async upgrade(cluster: Cluster): Promise<void>;
|
||||||
|
|
||||||
abstract async uninstall(cluster: Cluster): Promise<boolean>;
|
abstract async uninstall(cluster: Cluster): Promise<void>;
|
||||||
|
|
||||||
abstract async featureStatus(kc: KubeConfig): Promise<FeatureStatus>;
|
abstract async featureStatus(kc: KubeConfig): Promise<FeatureStatus>;
|
||||||
|
|
||||||
constructor(public config: any) {
|
constructor(public config: any) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async install(cluster: Cluster): Promise<boolean> {
|
async install(cluster: Cluster): Promise<void> {
|
||||||
const resources = this.renderTemplates();
|
const resources = this.renderTemplates();
|
||||||
try {
|
try {
|
||||||
await new ResourceApplier(cluster).kubectlApplyAll(resources);
|
await new ResourceApplier(cluster).kubectlApplyAll(resources);
|
||||||
return true;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("Installing feature error", { err, cluster });
|
logger.error("Installing feature error", { err, cluster });
|
||||||
return false
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -31,25 +31,30 @@ export class KubeAuthProxy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const proxyBin = await this.kubectl.getPath()
|
const proxyBin = await this.kubectl.getPath()
|
||||||
let args = [
|
const args = [
|
||||||
"proxy",
|
"proxy",
|
||||||
"-p", this.port.toString(),
|
"-p", `${this.port}`,
|
||||||
"--kubeconfig", this.cluster.kubeConfigPath,
|
// "--kubeconfig", `"${this.cluster.kubeConfigPath}"`,
|
||||||
"--context", this.cluster.contextName,
|
// "--context", `"${this.cluster.contextName}"`,
|
||||||
|
"--kubeconfig", `${this.cluster.kubeConfigPath}`,
|
||||||
|
"--context", `${this.cluster.contextName}`,
|
||||||
"--accept-hosts", ".*",
|
"--accept-hosts", ".*",
|
||||||
"--reject-paths", "^[^/]"
|
"--reject-paths", "^[^/]"
|
||||||
]
|
]
|
||||||
if (process.env.DEBUG_PROXY === "true") {
|
if (process.env.DEBUG_PROXY === "true") {
|
||||||
args = args.concat(["-v", "9"])
|
args.push("-v", "9")
|
||||||
}
|
}
|
||||||
logger.debug(`spawning kubectl proxy with args: ${args}`)
|
logger.debug(`spawning kubectl proxy with args: ${args}`)
|
||||||
this.proxyProcess = spawn(proxyBin, args, {
|
this.proxyProcess = spawn(proxyBin, args, { env: this.env, })
|
||||||
env: this.env
|
|
||||||
})
|
|
||||||
this.proxyProcess.on("exit", (code) => {
|
this.proxyProcess.on("exit", (code) => {
|
||||||
this.sendIpcLogMessage({ data: `proxy exited with code: ${code}`, error: code > 0 })
|
this.sendIpcLogMessage({ data: `proxy exited with code: ${code}`, error: code > 0 })
|
||||||
this.proxyProcess = null
|
this.proxyProcess.removeAllListeners();
|
||||||
|
this.proxyProcess.stderr.removeAllListeners();
|
||||||
|
this.proxyProcess.stdout.removeAllListeners();
|
||||||
|
this.proxyProcess = null;
|
||||||
})
|
})
|
||||||
|
|
||||||
this.proxyProcess.stdout.on('data', (data) => {
|
this.proxyProcess.stdout.on('data', (data) => {
|
||||||
let logItem = data.toString()
|
let logItem = data.toString()
|
||||||
if (logItem.startsWith("Starting to serve on")) {
|
if (logItem.startsWith("Starting to serve on")) {
|
||||||
@ -57,6 +62,7 @@ export class KubeAuthProxy {
|
|||||||
}
|
}
|
||||||
this.sendIpcLogMessage({ data: logItem })
|
this.sendIpcLogMessage({ data: logItem })
|
||||||
})
|
})
|
||||||
|
|
||||||
this.proxyProcess.stderr.on('data', (data) => {
|
this.proxyProcess.stderr.on('data', (data) => {
|
||||||
this.lastError = this.parseError(data.toString())
|
this.lastError = this.parseError(data.toString())
|
||||||
this.sendIpcLogMessage({ data: data.toString(), error: true })
|
this.sendIpcLogMessage({ data: data.toString(), error: true })
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export class ResourceApplier {
|
|||||||
return new Promise<string>((resolve, reject) => {
|
return new Promise<string>((resolve, reject) => {
|
||||||
const fileName = tempy.file({ name: "resource.yaml" })
|
const fileName = tempy.file({ name: "resource.yaml" })
|
||||||
fs.writeFileSync(fileName, content)
|
fs.writeFileSync(fileName, content)
|
||||||
const cmd = `"${kubectlPath}" apply --kubeconfig "${kubeConfigPath}" -o json -f ${fileName}`
|
const cmd = `"${kubectlPath}" apply --kubeconfig "${kubeConfigPath}" -o json -f "${fileName}"`
|
||||||
logger.debug("shooting manifests with: " + cmd);
|
logger.debug("shooting manifests with: " + cmd);
|
||||||
const execEnv: NodeJS.ProcessEnv = Object.assign({}, process.env)
|
const execEnv: NodeJS.ProcessEnv = Object.assign({}, process.env)
|
||||||
const httpsProxy = this.cluster.preferences?.httpsProxy
|
const httpsProxy = this.cluster.preferences?.httpsProxy
|
||||||
@ -54,7 +54,7 @@ export class ResourceApplier {
|
|||||||
resources.forEach((resource, index) => {
|
resources.forEach((resource, index) => {
|
||||||
fs.writeFileSync(path.join(tmpDir, `${index}.yaml`), resource);
|
fs.writeFileSync(path.join(tmpDir, `${index}.yaml`), resource);
|
||||||
})
|
})
|
||||||
const cmd = `"${kubectlPath}" apply --kubeconfig "${kubeConfigPath}" -o json -f ${tmpDir}`
|
const cmd = `"${kubectlPath}" apply --kubeconfig "${kubeConfigPath}" -o json -f "${tmpDir}"`
|
||||||
console.log("shooting manifests with:", cmd);
|
console.log("shooting manifests with:", cmd);
|
||||||
exec(cmd, (error, stdout, stderr) => {
|
exec(cmd, (error, stdout, stderr) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@ -1,3 +1,86 @@
|
|||||||
.ClusterSettings {
|
.ClusterSettings {
|
||||||
|
overflow-y: scroll;
|
||||||
|
grid-template-columns: unset;
|
||||||
|
|
||||||
|
.info-col {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-col {
|
||||||
|
margin-right: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin-top: 40px;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-table {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 3fr;
|
||||||
|
grid-gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.Input,.Select {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Icon:not(.updated):not(.clean) {
|
||||||
|
color: #ad0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Icon.updated {
|
||||||
|
color: #00dd1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updated {
|
||||||
|
animation: updated-name 1s 1;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
animation-delay: 3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes updated-name {
|
||||||
|
from {opacity :1;}
|
||||||
|
to {opacity :0;}
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]::placeholder {
|
||||||
|
font-size: small;
|
||||||
|
color: #707070;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-top: 5px;
|
||||||
|
|
||||||
|
.Spinner {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-color: transparent black;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,14 +1,25 @@
|
|||||||
import "./cluster-settings.scss"
|
import "./cluster-settings.scss"
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
|
import { Features } from "./features"
|
||||||
|
import { Removal } from "./removal"
|
||||||
|
import { Status } from "./status"
|
||||||
|
import { General } from "./general"
|
||||||
|
import { getHostedCluster } from "../../../common/cluster-store"
|
||||||
|
import { WizardLayout } from "../layout/wizard-layout";
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class ClusterSettings extends React.Component {
|
export class ClusterSettings extends React.Component {
|
||||||
render() {
|
render() {
|
||||||
|
const cluster = getHostedCluster();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ClusterSettings">
|
<WizardLayout className="ClusterSettings">
|
||||||
ClusterSettings
|
<Status cluster={cluster}></Status>
|
||||||
</div>
|
<General cluster={cluster}></General>
|
||||||
);
|
<Features cluster={cluster}></Features>
|
||||||
|
<Removal cluster={cluster}></Removal>
|
||||||
|
</WizardLayout>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,86 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Cluster } from "../../../../main/cluster";
|
||||||
|
import { Input } from "../../input";
|
||||||
|
import { Spinner } from "../../spinner";
|
||||||
|
import { clusterStore } from "../../../../common/cluster-store"
|
||||||
|
import { Icon } from "../../icon";
|
||||||
|
import { Tooltip, TooltipPosition } from "../../tooltip";
|
||||||
|
import { autobind } from "../../../utils";
|
||||||
|
import { TextInputStatus } from "./statuses"
|
||||||
|
import { observable } from "mobx";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cluster: Cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class ClusterHomeDirSetting extends React.Component<Props> {
|
||||||
|
@observable directory = this.props.cluster.preferences.terminalCWD || "";
|
||||||
|
@observable status = TextInputStatus.CLEAN;
|
||||||
|
@observable errorText?: string;
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <>
|
||||||
|
<h4>Working Directory</h4>
|
||||||
|
<p>Set initial working directory for terminals. When set it will the `pwd` when a new terminal instance is opened for this cluster.</p>
|
||||||
|
<Input
|
||||||
|
theme="round-black"
|
||||||
|
className="box grow"
|
||||||
|
value={this.directory}
|
||||||
|
onSubmit={this.onWorkingDirectorySubmit}
|
||||||
|
onChange={this.onWorkingDirectoryChange}
|
||||||
|
iconRight={this.getIconRight()}
|
||||||
|
placeholder="$HOME"
|
||||||
|
/>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@autobind()
|
||||||
|
onWorkingDirectoryChange(directory: string, _e: React.ChangeEvent) {
|
||||||
|
if (this.status === TextInputStatus.UPDATING) {
|
||||||
|
console.log("prevent changing cluster directory while updating");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.status = this.dirDiffers(directory);
|
||||||
|
this.directory = directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
dirDiffers(directory: string): TextInputStatus {
|
||||||
|
const { terminalCWD = "" } = this.props.cluster.preferences;
|
||||||
|
|
||||||
|
return directory === terminalCWD ? TextInputStatus.CLEAN : TextInputStatus.DIRTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIconRight(): React.ReactNode {
|
||||||
|
switch (this.status) {
|
||||||
|
case TextInputStatus.CLEAN:
|
||||||
|
return null;
|
||||||
|
case TextInputStatus.DIRTY:
|
||||||
|
return <Icon size="16px" material="fiber_manual_record"/>;
|
||||||
|
case TextInputStatus.UPDATED:
|
||||||
|
return <Icon size="16px" className="updated" material="done"/>;
|
||||||
|
case TextInputStatus.UPDATING:
|
||||||
|
return <Spinner />;
|
||||||
|
case TextInputStatus.ERROR:
|
||||||
|
return <Icon id="cluster-directory-setting-error-icon" size="16px" material="error">
|
||||||
|
<Tooltip targetId="cluster-directory-setting-error-icon" position={TooltipPosition.TOP}>
|
||||||
|
{this.errorText}
|
||||||
|
</Tooltip>
|
||||||
|
</Icon>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@autobind()
|
||||||
|
onWorkingDirectorySubmit(directory: string) {
|
||||||
|
if (this.dirDiffers(directory) !== TextInputStatus.DIRTY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.status = TextInputStatus.UPDATING
|
||||||
|
this.props.cluster.preferences.terminalCWD = directory;
|
||||||
|
this.directory = directory;
|
||||||
|
this.status = TextInputStatus.UPDATED
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Cluster } from "../../../../main/cluster";
|
||||||
|
import { clusterStore } from "../../../../common/cluster-store"
|
||||||
|
import { Icon } from "../../icon";
|
||||||
|
import { FilePicker, OverSizeLimitStyle } from "../../file-picker";
|
||||||
|
import { autobind } from "../../../utils";
|
||||||
|
import { Button } from "../../button";
|
||||||
|
import { GeneralInputStatus } from "./statuses"
|
||||||
|
import { observable } from "mobx";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cluster: Cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class ClusterIconSetting extends React.Component<Props> {
|
||||||
|
@observable status = GeneralInputStatus.CLEAN;
|
||||||
|
@observable errorText?: string;
|
||||||
|
|
||||||
|
@autobind()
|
||||||
|
async onIconPick([file]: File[]) {
|
||||||
|
const { cluster } = this.props;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (file) {
|
||||||
|
const buf = Buffer.from(await file.arrayBuffer());
|
||||||
|
cluster.preferences.icon = `data:image/jpeg;base64, ${buf.toString('base64')}`;
|
||||||
|
} else {
|
||||||
|
// this has to be done as a seperate branch (and not always) because `cluster`
|
||||||
|
// is observable and triggers an update loop.
|
||||||
|
cluster.preferences.icon = undefined;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.errorText = e.toString()
|
||||||
|
this.status = GeneralInputStatus.ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getClearButton() {
|
||||||
|
const { cluster } = this.props;
|
||||||
|
|
||||||
|
if (cluster.preferences.icon) {
|
||||||
|
return <Button accent onClick={() => this.onIconPick([])}>Clear</Button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <>
|
||||||
|
<h4>Cluster Icon</h4>
|
||||||
|
<p>Set cluster icon. By default it is automatically generated. {this.getIconRight()}</p>
|
||||||
|
<div className="center">
|
||||||
|
<FilePicker
|
||||||
|
accept="image/*"
|
||||||
|
labelText="Browse for new icon..."
|
||||||
|
onOverSizeLimit={OverSizeLimitStyle.FILTER}
|
||||||
|
handler={this.onIconPick}
|
||||||
|
/>
|
||||||
|
{this.getClearButton()}
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIconRight(): React.ReactNode {
|
||||||
|
switch (this.status) {
|
||||||
|
case GeneralInputStatus.CLEAN:
|
||||||
|
return null;
|
||||||
|
case GeneralInputStatus.ERROR:
|
||||||
|
return <Icon size="16px" material="error" title={this.errorText}></Icon>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Cluster } from "../../../../main/cluster";
|
||||||
|
import { Input } from "../../input";
|
||||||
|
import { Spinner } from "../../spinner";
|
||||||
|
import { clusterStore } from "../../../../common/cluster-store"
|
||||||
|
import { Icon } from "../../icon";
|
||||||
|
import { Tooltip, TooltipPosition } from "../../tooltip";
|
||||||
|
import { autobind } from "../../../utils";
|
||||||
|
import { TextInputStatus } from "./statuses"
|
||||||
|
import { observable } from "mobx";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cluster: Cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class ClusterNameSetting extends React.Component<Props> {
|
||||||
|
@observable name = this.props.cluster.preferences.clusterName || "";
|
||||||
|
@observable status = TextInputStatus.CLEAN;
|
||||||
|
@observable errorText?: string;
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <>
|
||||||
|
<h4>Cluster Name</h4>
|
||||||
|
<p>Change cluster name:</p>
|
||||||
|
<Input
|
||||||
|
theme="round-black"
|
||||||
|
className="box grow"
|
||||||
|
value={this.name}
|
||||||
|
onSubmit={this.onClusterNameSubmit}
|
||||||
|
onChange={this.onClusterNameChange}
|
||||||
|
iconRight={this.getIconRight()}
|
||||||
|
/>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@autobind()
|
||||||
|
onClusterNameChange(name: string, _e: React.ChangeEvent) {
|
||||||
|
if (this.status === TextInputStatus.UPDATING) {
|
||||||
|
console.log("prevent changing cluster name while updating");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.status = this.nameDiffers(name)
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
nameDiffers(name: string): TextInputStatus {
|
||||||
|
const { clusterName } = this.props.cluster.preferences;
|
||||||
|
|
||||||
|
return name === clusterName ? TextInputStatus.CLEAN : TextInputStatus.DIRTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIconRight(): React.ReactNode {
|
||||||
|
switch (this.status) {
|
||||||
|
case TextInputStatus.CLEAN:
|
||||||
|
return null;
|
||||||
|
case TextInputStatus.DIRTY:
|
||||||
|
return <Icon size="16px" material="fiber_manual_record"/>;
|
||||||
|
case TextInputStatus.UPDATED:
|
||||||
|
return <Icon size="16px" className="updated" material="done"/>;
|
||||||
|
case TextInputStatus.UPDATING:
|
||||||
|
return <Spinner/>;
|
||||||
|
case TextInputStatus.ERROR:
|
||||||
|
return <Icon id="cluster-name-setting-error-icon" size="16px" material="error">
|
||||||
|
<Tooltip targetId="cluster-name-setting-error-icon" position={TooltipPosition.TOP}>
|
||||||
|
{this.errorText}
|
||||||
|
</Tooltip>
|
||||||
|
</Icon>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@autobind()
|
||||||
|
onClusterNameSubmit(name: string) {
|
||||||
|
if (this.nameDiffers(name) !== TextInputStatus.DIRTY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.status = TextInputStatus.UPDATING
|
||||||
|
this.props.cluster.preferences.clusterName = name;
|
||||||
|
this.name = name;
|
||||||
|
this.status = TextInputStatus.UPDATED
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Cluster } from "../../../../main/cluster";
|
||||||
|
import { clusterStore } from "../../../../common/cluster-store"
|
||||||
|
import { Select, SelectOption, SelectProps } from "../../select";
|
||||||
|
import { prometheusProviders } from "../../../../common/prometheus-providers";
|
||||||
|
import { autobind } from "../../../utils";
|
||||||
|
import { observable } from "mobx";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
|
||||||
|
const prometheusGuide = "https://github.com/lensapp/lens/blob/master/troubleshooting/custom-prometheus.md";
|
||||||
|
const options: SelectOption<string>[] = [
|
||||||
|
{ value: "", label: "Auto detect" },
|
||||||
|
...prometheusProviders.map(pp => ({value: pp.id, label: pp.name}))
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cluster: Cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class ClusterPrometheusSetting extends React.Component<Props> {
|
||||||
|
@observable prometheusProvider = this.props.cluster.preferences.prometheusProvider?.type || "";
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <>
|
||||||
|
<h4>Cluster Prometheus</h4>
|
||||||
|
<p>Use pre-installed Prometheus service for metrics. Please refer to <a href={prometheusGuide}>this guide</a> for possible configuration changes.</p>
|
||||||
|
<Select
|
||||||
|
value={this.prometheusProvider}
|
||||||
|
options={options}
|
||||||
|
onChange={this.changePrometheusProvider}
|
||||||
|
/>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@autobind()
|
||||||
|
changePrometheusProvider({ value: prometheusProvider }: SelectProps<string>) {
|
||||||
|
this.prometheusProvider = prometheusProvider;
|
||||||
|
this.props.cluster.preferences.prometheusProvider = { type: prometheusProvider };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,105 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Cluster } from "../../../../main/cluster";
|
||||||
|
import { Input } from "../../input";
|
||||||
|
import { Spinner } from "../../spinner";
|
||||||
|
import { clusterStore } from "../../../../common/cluster-store"
|
||||||
|
import { Icon } from "../../icon";
|
||||||
|
import { Tooltip, TooltipPosition } from "../../tooltip";
|
||||||
|
import { autobind } from "../../../utils";
|
||||||
|
import { TextInputStatus } from "./statuses"
|
||||||
|
import { observable } from "mobx";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cluster: Cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class ClusterProxySetting extends React.Component<Props> {
|
||||||
|
@observable proxy = this.props.cluster.preferences.httpsProxy || "";
|
||||||
|
@observable status = TextInputStatus.CLEAN;
|
||||||
|
@observable errorText?: string;
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <>
|
||||||
|
<h4>HTTPS Proxy</h4>
|
||||||
|
<p>HTTPS Proxy server. Used for communicating with Kubernetes API.</p>
|
||||||
|
<Input
|
||||||
|
theme="round-black"
|
||||||
|
className="box grow"
|
||||||
|
value={this.proxy}
|
||||||
|
onSubmit={this.updateClusterProxy}
|
||||||
|
onChange={this.changeProxyState}
|
||||||
|
iconRight={this.getIconRight()}
|
||||||
|
placeholder="https://<address>:<port>"
|
||||||
|
/>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@autobind()
|
||||||
|
changeProxyState(proxy: string, _e: React.ChangeEvent) {
|
||||||
|
if (this.status === TextInputStatus.UPDATING) {
|
||||||
|
console.log("prevent changing cluster proxy while updating");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.status = this.proxyDiffers(proxy);
|
||||||
|
this.proxy = proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyDiffers(proxy: string): TextInputStatus {
|
||||||
|
const { httpsProxy = "" } = this.props.cluster.preferences;
|
||||||
|
|
||||||
|
return proxy === httpsProxy ? TextInputStatus.CLEAN : TextInputStatus.DIRTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIconRight(): React.ReactNode {
|
||||||
|
switch (this.status) {
|
||||||
|
case TextInputStatus.CLEAN:
|
||||||
|
return null;
|
||||||
|
case TextInputStatus.DIRTY:
|
||||||
|
return <Icon size="16px" material="fiber_manual_record"/>;
|
||||||
|
case TextInputStatus.UPDATED:
|
||||||
|
return <Icon size="16px" className="updated" material="done"/>;
|
||||||
|
case TextInputStatus.UPDATING:
|
||||||
|
return <Spinner />;
|
||||||
|
case TextInputStatus.ERROR:
|
||||||
|
return <Icon id="cluster-proxy-setting-error-icon" size="16px" material="error">
|
||||||
|
<Tooltip targetId="cluster-proxy-setting-error-icon" position={TooltipPosition.TOP}>
|
||||||
|
{this.errorText}
|
||||||
|
</Tooltip>
|
||||||
|
</Icon>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@autobind()
|
||||||
|
updateClusterProxy(proxy: string) {
|
||||||
|
if (this.proxyDiffers(proxy) !== TextInputStatus.DIRTY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(proxy);
|
||||||
|
|
||||||
|
if (url.protocol !== "https") {
|
||||||
|
this.status = TextInputStatus.ERROR
|
||||||
|
this.errorText= `Proxy's protocol should be "https"`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (url.port === "") {
|
||||||
|
this.status = TextInputStatus.ERROR
|
||||||
|
this.errorText= "Proxy should include a port"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.status = TextInputStatus.ERROR
|
||||||
|
this.errorText= "Invalid URL"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.status = TextInputStatus.UPDATING
|
||||||
|
this.props.cluster.preferences.httpsProxy = proxy;
|
||||||
|
this.proxy = proxy;
|
||||||
|
this.status = TextInputStatus.UPDATED
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Cluster } from "../../../../main/cluster";
|
||||||
|
import { clusterStore } from "../../../../common/cluster-store"
|
||||||
|
import { workspaceStore } from "../../../../common/workspace-store"
|
||||||
|
import { Select, SelectOption } from "../../../components/select";
|
||||||
|
import { GeneralInputStatus } from "./statuses"
|
||||||
|
import { observable } from "mobx";
|
||||||
|
import { autobind } from "../../../utils";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cluster: Cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class ClusterWorkspaceSetting extends React.Component<Props> {
|
||||||
|
@observable workspace = this.props.cluster.workspace;
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <>
|
||||||
|
<h4>Cluster Workspace</h4>
|
||||||
|
<p>Change cluster workspace:</p>
|
||||||
|
<Select
|
||||||
|
value={workspaceStore.currentWorkspaceId}
|
||||||
|
options={workspaceStore.workspacesList.map(w => ({value: w.id, label: <span>{w.name}</span>}))}
|
||||||
|
onChange={this.changeWorkspace}
|
||||||
|
/>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@autobind()
|
||||||
|
changeWorkspace({ value: workspace }: SelectOption<string>) {
|
||||||
|
this.workspace = workspace;
|
||||||
|
this.props.cluster.workspace = workspace;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Cluster } from "../../../../main/cluster";
|
||||||
|
import { Button } from "../../button";
|
||||||
|
import { autobind } from "../../../utils";
|
||||||
|
import { Tooltip, TooltipPosition } from "../../tooltip";
|
||||||
|
import { MetricsFeature } from "../../../../features/metrics";
|
||||||
|
import { Spinner } from "../../spinner";
|
||||||
|
import { Icon } from "../../icon";
|
||||||
|
import { clusterIpc } from "../../../../common/cluster-ipc";
|
||||||
|
import { observable } from "mobx";
|
||||||
|
import { ActionStatus } from "./statuses"
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cluster: Cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class InstallMetrics extends React.Component<Props> {
|
||||||
|
@observable status = ActionStatus.IDLE;
|
||||||
|
@observable errorText?: string;
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <>
|
||||||
|
<h4>Metrics</h4>
|
||||||
|
<p>
|
||||||
|
User Mode feature enables non-admin users to see namespaces they have access to.
|
||||||
|
This is achieved by configuring RBAC rules so that every authenticated user is granted to list namespaces.
|
||||||
|
</p>
|
||||||
|
<div className="center">
|
||||||
|
{this.getActionButtons()}
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusIcon(): React.ReactNode {
|
||||||
|
switch (this.status) {
|
||||||
|
case ActionStatus.IDLE:
|
||||||
|
return null;
|
||||||
|
case ActionStatus.PROCESSING:
|
||||||
|
return <Spinner />;
|
||||||
|
case ActionStatus.ERROR:
|
||||||
|
return <Icon size="16px" material="error" title={this.errorText}></Icon>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisabledToolTip(id: string, action: string): React.ReactNode {
|
||||||
|
const { cluster } = this.props;
|
||||||
|
if (cluster.isAdmin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip targetId={id} position={TooltipPosition.TOP}>
|
||||||
|
{action} only allowed by admins
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getActionButtons(): React.ReactNode[] {
|
||||||
|
const { cluster } = this.props
|
||||||
|
const buttons = [];
|
||||||
|
|
||||||
|
if (cluster.features[MetricsFeature.id]?.canUpgrade) {
|
||||||
|
buttons.push(
|
||||||
|
<Button key="upgrade" id="cluster-feature-metrics-upgrade" disabled={!cluster.isAdmin} primary onClick={this.runAction("upgradeFeature")}>
|
||||||
|
Upgrade {this.getStatusIcon()} {this.getDisabledToolTip("cluster-feature-metrics-upgrade", "Upgrading")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cluster.features[MetricsFeature.id]?.installed) {
|
||||||
|
buttons.push(
|
||||||
|
<Button key="uninstall" id="cluster-feature-metrics-uninstall" disabled={!cluster.isAdmin} primary onClick={this.runAction("uninstallFeature")}>
|
||||||
|
Uninstall {this.getStatusIcon()} {this.getDisabledToolTip("cluster-feature-metrics-uninstall", "Uninstalling")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
buttons.push(
|
||||||
|
<Button key="install" id="cluster-feature-metrics-install" disabled={!cluster.isAdmin} primary onClick={this.runAction("installFeature")}>
|
||||||
|
Install {this.getStatusIcon()} {this.getDisabledToolTip("cluster-feature-metrics-install", "Installing")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buttons;
|
||||||
|
}
|
||||||
|
|
||||||
|
runAction(action: keyof typeof clusterIpc): () => Promise<void> {
|
||||||
|
return async () => {
|
||||||
|
const { cluster } = this.props;
|
||||||
|
console.log(`running ${action} ${MetricsFeature.id} onto ${cluster.preferences.clusterName}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.status = ActionStatus.PROCESSING
|
||||||
|
await clusterIpc[action].invokeFromRenderer(cluster.id, MetricsFeature.id);
|
||||||
|
try {
|
||||||
|
await cluster.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
this.status = ActionStatus.IDLE
|
||||||
|
} catch (err) {
|
||||||
|
this.status = ActionStatus.ERROR
|
||||||
|
this.errorText = err.toString()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,108 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Cluster } from "../../../../main/cluster";
|
||||||
|
import { Button } from "../../button";
|
||||||
|
import { autobind } from "../../../utils";
|
||||||
|
import { Tooltip, TooltipPosition } from "../../tooltip";
|
||||||
|
import { Spinner } from "../../spinner";
|
||||||
|
import { Icon } from "../../icon";
|
||||||
|
import { UserModeFeature } from "../../../../features/user-mode";
|
||||||
|
import { clusterIpc } from "../../../../common/cluster-ipc";
|
||||||
|
import { observable } from "mobx";
|
||||||
|
import { ActionStatus } from "./statuses"
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cluster: Cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class InstallUserMode extends React.Component<Props> {
|
||||||
|
@observable status = ActionStatus.IDLE;
|
||||||
|
@observable errorText?: string;
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <>
|
||||||
|
<h4>User Mode</h4>
|
||||||
|
<p>
|
||||||
|
User Mode feature enables non-admin users to see namespaces they have access to.
|
||||||
|
This is achieved by configuring RBAC rules so that every authenticated user is granted to list namespaces.
|
||||||
|
</p>
|
||||||
|
<div className="center">
|
||||||
|
{this.getActionButtons()}
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
getStatusIcon(): React.ReactNode {
|
||||||
|
switch (this.status) {
|
||||||
|
case ActionStatus.IDLE:
|
||||||
|
return null;
|
||||||
|
case ActionStatus.PROCESSING:
|
||||||
|
return <Spinner key="spinner" />;
|
||||||
|
case ActionStatus.ERROR:
|
||||||
|
return <Icon key="error" size="16px" material="error" title={this.errorText}></Icon>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisabledToolTip(id: string, action: string): React.ReactNode {
|
||||||
|
const { cluster } = this.props;
|
||||||
|
if (cluster.isAdmin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Tooltip targetId={id} position={TooltipPosition.TOP}>
|
||||||
|
{action} only allowed by admins
|
||||||
|
</Tooltip>;
|
||||||
|
}
|
||||||
|
|
||||||
|
getActionButtons(): React.ReactNode[] {
|
||||||
|
const { cluster } = this.props
|
||||||
|
const buttons = [];
|
||||||
|
|
||||||
|
if (cluster.features[UserModeFeature.id]?.canUpgrade) {
|
||||||
|
buttons.push(
|
||||||
|
<Button key="upgrade" id="cluster-feature-user-mode-upgrade" disabled={!cluster.isAdmin} primary onClick={this.runAction("upgradeFeature")}>
|
||||||
|
Upgrade {this.getStatusIcon()} {this.getDisabledToolTip("cluster-feature-user-mode-upgrade", "Upgrading")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cluster.features[UserModeFeature.id]?.installed) {
|
||||||
|
buttons.push(
|
||||||
|
<Button key="uninstall" id="cluster-feature-user-mode-uninstall" disabled={!cluster.isAdmin} primary onClick={this.runAction("uninstallFeature")}>
|
||||||
|
Uninstall {this.getStatusIcon()} {this.getDisabledToolTip("cluster-feature-user-mode-uninstall", "Uninstalling")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
buttons.push(
|
||||||
|
<Button key="install" id="cluster-feature-user-mode-install" disabled={!cluster.isAdmin} primary onClick={this.runAction("installFeature")}>
|
||||||
|
Install {this.getStatusIcon()} {this.getDisabledToolTip("cluster-feature-user-mode-install", "Installing")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buttons;
|
||||||
|
}
|
||||||
|
|
||||||
|
runAction(action: keyof typeof clusterIpc): () => Promise<void> {
|
||||||
|
return async () => {
|
||||||
|
const { cluster } = this.props;
|
||||||
|
console.log(`running ${action} ${UserModeFeature.id} onto ${cluster.preferences.clusterName}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.status = ActionStatus.PROCESSING
|
||||||
|
await clusterIpc[action].invokeFromRenderer(cluster.id, UserModeFeature.id);
|
||||||
|
try {
|
||||||
|
await cluster.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
this.status = ActionStatus.IDLE
|
||||||
|
} catch (err) {
|
||||||
|
this.status = ActionStatus.ERROR
|
||||||
|
this.errorText = err.toString()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Cluster } from "../../../../main/cluster";
|
||||||
|
import { Button } from "../../button";
|
||||||
|
import { autobind } from "../../../utils";
|
||||||
|
import { Spinner } from "../../spinner";
|
||||||
|
import { Icon } from "../../icon";
|
||||||
|
import { ConfirmDialog } from "../../confirm-dialog";
|
||||||
|
import { Trans } from "@lingui/macro";
|
||||||
|
import { clusterIpc } from "../../../../common/cluster-ipc";
|
||||||
|
import { clusterStore } from "../../../../common/cluster-store";
|
||||||
|
import { observable } from "mobx";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { RemovalStatus } from "./statuses"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cluster: Cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class RemoveClusterButton extends React.Component<Props> {
|
||||||
|
@observable status = RemovalStatus.PRESENT;
|
||||||
|
@observable errorText?: string;
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="center">
|
||||||
|
<Button accent onClick={this.confirmRemoveCluster}>Remove Cluster {this.getStatusIcon()}</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusIcon(): React.ReactNode {
|
||||||
|
switch (this.status) {
|
||||||
|
case RemovalStatus.PRESENT:
|
||||||
|
return null;
|
||||||
|
case RemovalStatus.PROCESSING:
|
||||||
|
return <Spinner />;
|
||||||
|
case RemovalStatus.ERROR:
|
||||||
|
return <Icon size="16px" material="error" title={this.errorText}></Icon>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@autobind()
|
||||||
|
confirmRemoveCluster() {
|
||||||
|
const { cluster } = this.props;
|
||||||
|
|
||||||
|
ConfirmDialog.open({
|
||||||
|
message: <p>Are you sure you want to remove <b>{cluster.preferences.clusterName}</b> from Lens?</p>,
|
||||||
|
labelOk: <Trans>Yes</Trans>,
|
||||||
|
labelCancel: <Trans>No</Trans>,
|
||||||
|
ok: async () => {
|
||||||
|
try {
|
||||||
|
this.status = RemovalStatus.PROCESSING;
|
||||||
|
await clusterIpc.disconnect.invokeFromRenderer(cluster.id);
|
||||||
|
await clusterStore.removeById(cluster.id);
|
||||||
|
} catch (err) {
|
||||||
|
this.status = RemovalStatus.ERROR;
|
||||||
|
this.errorText = err.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
export enum TextInputStatus {
|
||||||
|
CLEAN = "clean",
|
||||||
|
DIRTY = "dirty",
|
||||||
|
UPDATING = "updating",
|
||||||
|
ERROR = "error",
|
||||||
|
UPDATED = "updated",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum GeneralInputStatus {
|
||||||
|
CLEAN = "clean",
|
||||||
|
ERROR = "error",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ActionStatus {
|
||||||
|
IDLE = "idle",
|
||||||
|
PROCESSING = "processing",
|
||||||
|
ERROR = "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum RemovalStatus {
|
||||||
|
PRESENT = "present",
|
||||||
|
PROCESSING = "processing",
|
||||||
|
ERROR = "error",
|
||||||
|
}
|
||||||
20
src/renderer/components/+cluster-settings/features.tsx
Normal file
20
src/renderer/components/+cluster-settings/features.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Cluster } from "../../../main/cluster";
|
||||||
|
import { InstallMetrics } from "./components/install-metrics";
|
||||||
|
import { InstallUserMode } from "./components/install-user-mode";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cluster: Cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Features extends React.Component<Props> {
|
||||||
|
render() {
|
||||||
|
const { cluster } = this.props;
|
||||||
|
|
||||||
|
return <div>
|
||||||
|
<h2>Features</h2>
|
||||||
|
<InstallMetrics cluster={cluster}/>
|
||||||
|
<InstallUserMode cluster={cluster}/>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/renderer/components/+cluster-settings/general.tsx
Normal file
28
src/renderer/components/+cluster-settings/general.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Cluster } from "../../../main/cluster";
|
||||||
|
import { ClusterNameSetting } from "./components/cluster-name-setting";
|
||||||
|
import { ClusterWorkspaceSetting } from "./components/cluster-workspace-setting";
|
||||||
|
import { ClusterIconSetting } from "./components/cluster-icon-setting";
|
||||||
|
import { ClusterProxySetting } from "./components/cluster-proxy-setting";
|
||||||
|
import { ClusterPrometheusSetting } from "./components/cluster-prometheus-setting";
|
||||||
|
import { ClusterHomeDirSetting } from "./components/cluster-home-dir-setting";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cluster: Cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class General extends React.Component<Props> {
|
||||||
|
render() {
|
||||||
|
return <div>
|
||||||
|
<h2>General</h2>
|
||||||
|
<hr/>
|
||||||
|
|
||||||
|
<ClusterNameSetting cluster={this.props.cluster} />
|
||||||
|
<ClusterWorkspaceSetting cluster={this.props.cluster} />
|
||||||
|
<ClusterIconSetting cluster={this.props.cluster} />
|
||||||
|
<ClusterProxySetting cluster={this.props.cluster} />
|
||||||
|
<ClusterPrometheusSetting cluster={this.props.cluster} />
|
||||||
|
<ClusterHomeDirSetting cluster={this.props.cluster} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/renderer/components/+cluster-settings/removal.tsx
Normal file
18
src/renderer/components/+cluster-settings/removal.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Cluster } from "../../../main/cluster";
|
||||||
|
import { RemoveClusterButton } from "./components/remove-cluster-button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cluster: Cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Removal extends React.Component<Props> {
|
||||||
|
render() {
|
||||||
|
const { cluster } = this.props;
|
||||||
|
|
||||||
|
return <div>
|
||||||
|
<h2>Removal</h2>
|
||||||
|
<RemoveClusterButton cluster={cluster} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/renderer/components/+cluster-settings/status.tsx
Normal file
47
src/renderer/components/+cluster-settings/status.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Spinner } from "../spinner";
|
||||||
|
import { Cluster } from "../../../main/cluster";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cluster: Cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Status extends React.Component<Props> {
|
||||||
|
renderStatusRows(): JSX.Element[] {
|
||||||
|
const { cluster } = this.props;
|
||||||
|
|
||||||
|
const rows: [string, React.ReactNode][] = [
|
||||||
|
["Online Status", cluster.online ? "online" : `offline (${cluster.failureReason || "unknown reason"}`],
|
||||||
|
["Distribution", cluster.distribution],
|
||||||
|
["Kerbel Version", cluster.version],
|
||||||
|
["API Address", cluster.apiUrl],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (cluster.nodes > 0) {
|
||||||
|
rows.push(["Nodes Count", cluster.nodes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map(([header, value]) => [
|
||||||
|
<h5 key={header+"-header"}>{header}</h5>,
|
||||||
|
<span key={header + "-value"}>{value}</span>
|
||||||
|
])
|
||||||
|
.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { cluster } = this.props;
|
||||||
|
|
||||||
|
return <div>
|
||||||
|
<h2>Status</h2>
|
||||||
|
<hr/>
|
||||||
|
<h4>Cluster status</h4>
|
||||||
|
<p>
|
||||||
|
Cluster status information including: detected distribution, kernel version, and online status.
|
||||||
|
</p>
|
||||||
|
<div className="status-table">
|
||||||
|
{this.renderStatusRows()}
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -89,25 +89,28 @@ h1 {
|
|||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
@extend h1;
|
@extend h1;
|
||||||
font-size: 20px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
@extend h2;
|
@extend h2;
|
||||||
font-size: 17px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
@extend h2;
|
@extend h3;
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h5 {
|
h5 {
|
||||||
@extend h2;
|
@extend h4;
|
||||||
padding: $padding / 2 0;
|
padding: $padding / 2 0;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h6 {
|
h6 {
|
||||||
@extend h2;
|
@extend h5;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
small {
|
small {
|
||||||
|
|||||||
@ -46,8 +46,8 @@ export class ClusterIcon extends React.Component<Props> {
|
|||||||
{showTooltip && (
|
{showTooltip && (
|
||||||
<Tooltip targetId={clusterIconId}>{clusterName}</Tooltip>
|
<Tooltip targetId={clusterIconId}>{clusterName}</Tooltip>
|
||||||
)}
|
)}
|
||||||
{icon && <img src={icon} alt={clusterName}/>}
|
{icon && <img src={icon} alt={clusterName} />}
|
||||||
{!icon && <Hashicon value={clusterName} options={options}/>}
|
{!icon && <Hashicon value={clusterName} options={options} />}
|
||||||
{showErrors && isAdmin && eventCount > 0 && (
|
{showErrors && isAdmin && eventCount > 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
className={cssNames("events-count", errorClass)}
|
className={cssNames("events-count", errorClass)}
|
||||||
1
src/renderer/components/cluster-icon/index.ts
Normal file
1
src/renderer/components/cluster-icon/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./cluster-icon"
|
||||||
@ -28,10 +28,9 @@ export class ClusterManager extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@computed get isInactive() {
|
@computed get isInactive() {
|
||||||
const { activeCluster, activeClusterId } = clusterStore;
|
const { activeCluster, activeClusterId, clusters } = clusterStore;
|
||||||
const isActivatedBefore = activeCluster?.initialized;
|
const isActivatedBefore = activeCluster?.initialized;
|
||||||
if (isNoClustersView() || isActivatedBefore) return;
|
return clusters.size > 0 && !isActivatedBefore && activeClusterId !== getHostedClusterId();
|
||||||
return activeClusterId !== getHostedClusterId();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import type { Cluster } from "../../../main/cluster";
|
|||||||
import { userStore } from "../../../common/user-store";
|
import { userStore } from "../../../common/user-store";
|
||||||
import { ClusterId, clusterStore } from "../../../common/cluster-store";
|
import { ClusterId, clusterStore } from "../../../common/cluster-store";
|
||||||
import { workspaceStore } from "../../../common/workspace-store";
|
import { workspaceStore } from "../../../common/workspace-store";
|
||||||
import { ClusterIcon } from "../+cluster-settings/cluster-icon";
|
import { ClusterIcon } from "../cluster-icon";
|
||||||
import { Icon } from "../icon";
|
import { Icon } from "../icon";
|
||||||
import { cssNames, IClassName } from "../../utils";
|
import { cssNames, IClassName } from "../../utils";
|
||||||
import { Badge } from "../badge";
|
import { Badge } from "../badge";
|
||||||
|
|||||||
14
src/renderer/components/file-picker/file-picker.scss
Normal file
14
src/renderer/components/file-picker/file-picker.scss
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
.FilePicker {
|
||||||
|
input[type="file"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: inline-block;
|
||||||
|
border: medium solid;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
203
src/renderer/components/file-picker/file-picker.tsx
Normal file
203
src/renderer/components/file-picker/file-picker.tsx
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import "./file-picker.scss"
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import fse from "fs-extra";
|
||||||
|
import path from "path";
|
||||||
|
import { Icon } from "../icon";
|
||||||
|
import { Spinner } from "../spinner";
|
||||||
|
import { observable } from "mobx";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import _ from "lodash";
|
||||||
|
|
||||||
|
export interface FileUploadProps {
|
||||||
|
uploadDir: string;
|
||||||
|
rename?: boolean;
|
||||||
|
handler?(path: string[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryUseProps {
|
||||||
|
handler?(file: File[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FileInputStatus {
|
||||||
|
CLEAR = "clear",
|
||||||
|
PROCESSING = "processing",
|
||||||
|
ERROR = "error",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum OverLimitStyle {
|
||||||
|
REJECT = "reject",
|
||||||
|
CAP = "cap",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum OverSizeLimitStyle {
|
||||||
|
REJECT = "reject",
|
||||||
|
FILTER = "filter",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum OverTotalSizeLimitStyle {
|
||||||
|
REJECT = "reject",
|
||||||
|
FILTER_LAST = "filter-last",
|
||||||
|
FILTER_LARGEST = "filter-largest",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseProps {
|
||||||
|
accept?: string;
|
||||||
|
labelText: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
|
||||||
|
// limit is the optional maximum number of files to upload
|
||||||
|
// the larger number is upper limit, the lower is lower limit
|
||||||
|
// the lower limit is capped at 0 and the upper limit is capped at Infinity
|
||||||
|
limit?: [number, number];
|
||||||
|
|
||||||
|
// default is "Reject"
|
||||||
|
onOverLimit?: OverLimitStyle;
|
||||||
|
|
||||||
|
// individual files are checked before the total size.
|
||||||
|
maxSize?: number;
|
||||||
|
// default is "Reject"
|
||||||
|
onOverSizeLimit?: OverSizeLimitStyle;
|
||||||
|
|
||||||
|
maxTotalSize?: number;
|
||||||
|
// default is "Reject"
|
||||||
|
onOverTotalSizeLimit?: OverTotalSizeLimitStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Props = BaseProps & (MemoryUseProps | FileUploadProps);
|
||||||
|
|
||||||
|
const defaultProps: Partial<Props> = {
|
||||||
|
maxSize: Infinity,
|
||||||
|
onOverSizeLimit: OverSizeLimitStyle.REJECT,
|
||||||
|
maxTotalSize: Infinity,
|
||||||
|
onOverLimit: OverLimitStyle.REJECT,
|
||||||
|
onOverTotalSizeLimit: OverTotalSizeLimitStyle.REJECT,
|
||||||
|
};
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class FilePicker extends React.Component<Props> {
|
||||||
|
static defaultProps = defaultProps as Object;
|
||||||
|
|
||||||
|
@observable status = FileInputStatus.CLEAR;
|
||||||
|
@observable errorText?: string;
|
||||||
|
|
||||||
|
handleFileCount(files: File[]): File[] {
|
||||||
|
const { limit: [minLimit, maxLimit] = [0, Infinity], onOverLimit } = this.props;
|
||||||
|
if (files.length > maxLimit) {
|
||||||
|
switch (onOverLimit) {
|
||||||
|
case OverLimitStyle.CAP:
|
||||||
|
files.length = maxLimit;
|
||||||
|
break;
|
||||||
|
case OverLimitStyle.REJECT:
|
||||||
|
throw `Too many files. Expected at most ${maxLimit}. Got ${files.length}.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (files.length < minLimit) {
|
||||||
|
throw `Too many files. Expected at most ${maxLimit}. Got ${files.length}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleIndiviualFileSizes(files: File[]): File[] {
|
||||||
|
const { onOverSizeLimit, maxSize } = this.props;
|
||||||
|
|
||||||
|
switch (onOverSizeLimit) {
|
||||||
|
case OverSizeLimitStyle.FILTER:
|
||||||
|
return files.filter(file => file.size <= maxSize );
|
||||||
|
case OverSizeLimitStyle.REJECT:
|
||||||
|
const firstFileToLarge = files.find(file => file.size > maxSize);
|
||||||
|
if (firstFileToLarge) {
|
||||||
|
throw `${firstFileToLarge.name} is too large. Maximum size is ${maxSize}. Has size of ${firstFileToLarge.size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTotalFileSizes(files: File[]): File[] {
|
||||||
|
const { maxTotalSize, onOverTotalSizeLimit } = this.props;
|
||||||
|
|
||||||
|
const totalSize = _.sum(files.map(f => f.size));
|
||||||
|
if (totalSize <= maxTotalSize) {
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (onOverTotalSizeLimit) {
|
||||||
|
case OverTotalSizeLimitStyle.FILTER_LARGEST:
|
||||||
|
files = _.orderBy(files, ["size"])
|
||||||
|
case OverTotalSizeLimitStyle.FILTER_LAST:
|
||||||
|
let newTotalSize = totalSize;
|
||||||
|
|
||||||
|
for (;files.length > 0;) {
|
||||||
|
newTotalSize -= files.pop().size;
|
||||||
|
if (newTotalSize <= maxTotalSize) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
case OverTotalSizeLimitStyle.REJECT:
|
||||||
|
throw `Total file size to upload is too large. Expected at most ${maxTotalSize}. Found ${totalSize}.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handlePickFiles(selectedFiles: FileList) {
|
||||||
|
const files: File[] = Array.from(selectedFiles);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const numberLimitedFiles = this.handleFileCount(files);
|
||||||
|
const sizeLimitedFiles = this.handleIndiviualFileSizes(numberLimitedFiles);
|
||||||
|
const totalSizeLimitedFiles = this.handleTotalFileSizes(sizeLimitedFiles);
|
||||||
|
|
||||||
|
if ("uploadDir" in this.props) {
|
||||||
|
const { uploadDir } = this.props;
|
||||||
|
this.status = FileInputStatus.PROCESSING;
|
||||||
|
|
||||||
|
const paths: string[] = [];
|
||||||
|
const promises = totalSizeLimitedFiles.map(async file => {
|
||||||
|
const destinationPath = path.join(uploadDir, file.name);
|
||||||
|
paths.push(destinationPath);
|
||||||
|
|
||||||
|
return fse.copyFile(file.path, destinationPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
this.props.handler(paths);
|
||||||
|
this.status = FileInputStatus.CLEAR;
|
||||||
|
} else {
|
||||||
|
this.props.handler(totalSizeLimitedFiles);
|
||||||
|
}
|
||||||
|
} catch (errorText) {
|
||||||
|
this.status = FileInputStatus.ERROR;
|
||||||
|
this.errorText = errorText;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { accept, labelText, multiple } = this.props;
|
||||||
|
|
||||||
|
return <div className="FilePicker">
|
||||||
|
<label htmlFor="file-upload">{labelText} {this.getIconRight()}</label>
|
||||||
|
<input
|
||||||
|
id="file-upload"
|
||||||
|
name="FilePicker"
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
multiple={multiple}
|
||||||
|
onChange={(event) => this.handlePickFiles(event.target.files)}
|
||||||
|
/>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIconRight(): React.ReactNode {
|
||||||
|
switch (this.status) {
|
||||||
|
case FileInputStatus.CLEAR:
|
||||||
|
return <Icon className="clean" material="cloud_upload"></Icon>
|
||||||
|
case FileInputStatus.PROCESSING:
|
||||||
|
return <Spinner />;
|
||||||
|
case FileInputStatus.ERROR:
|
||||||
|
return <Icon material="error" title={this.errorText}></Icon>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/renderer/components/file-picker/index.ts
Normal file
1
src/renderer/components/file-picker/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./file-picker"
|
||||||
@ -13,7 +13,7 @@ type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>
|
|||||||
type InputElement = HTMLInputElement | HTMLTextAreaElement;
|
type InputElement = HTMLInputElement | HTMLTextAreaElement;
|
||||||
type InputElementProps = InputHTMLAttributes<InputElement> & TextareaHTMLAttributes<InputElement> & DOMAttributes<InputElement>;
|
type InputElementProps = InputHTMLAttributes<InputElement> & TextareaHTMLAttributes<InputElement> & DOMAttributes<InputElement>;
|
||||||
|
|
||||||
export type InputProps<T = string> = Omit<InputElementProps, "onChange"> & {
|
export type InputProps<T = string> = Omit<InputElementProps, "onChange" | "onSubmit"> & {
|
||||||
theme?: "round-black";
|
theme?: "round-black";
|
||||||
className?: string;
|
className?: string;
|
||||||
value?: T;
|
value?: T;
|
||||||
@ -25,6 +25,7 @@ export type InputProps<T = string> = Omit<InputElementProps, "onChange"> & {
|
|||||||
iconRight?: string | React.ReactNode;
|
iconRight?: string | React.ReactNode;
|
||||||
validators?: Validator | Validator[];
|
validators?: Validator | Validator[];
|
||||||
onChange?(value: T, evt: React.ChangeEvent<InputElement>): void;
|
onChange?(value: T, evt: React.ChangeEvent<InputElement>): void;
|
||||||
|
onSubmit?(value: T): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
@ -100,7 +101,7 @@ export class Input extends React.Component<InputProps, State> {
|
|||||||
async validate(value = this.getValue()) {
|
async validate(value = this.getValue()) {
|
||||||
let validationId = (this.validationId = ""); // reset every time for async validators
|
let validationId = (this.validationId = ""); // reset every time for async validators
|
||||||
const asyncValidators: Promise<any>[] = [];
|
const asyncValidators: Promise<any>[] = [];
|
||||||
let errors: React.ReactNode[] = [];
|
const errors: React.ReactNode[] = [];
|
||||||
|
|
||||||
// run validators
|
// run validators
|
||||||
for (const validator of this.validators) {
|
for (const validator of this.validators) {
|
||||||
@ -130,12 +131,10 @@ export class Input extends React.Component<InputProps, State> {
|
|||||||
|
|
||||||
// handle async validators result
|
// handle async validators result
|
||||||
if (asyncValidators.length > 0) {
|
if (asyncValidators.length > 0) {
|
||||||
this.setState({ validating: true, valid: false, });
|
this.setState({ validating: true, valid: false });
|
||||||
const asyncErrors = await Promise.all(asyncValidators);
|
const asyncErrors = await Promise.all(asyncValidators);
|
||||||
const isLastValidationCheck = this.validationId === validationId;
|
if (this.validationId === validationId) {
|
||||||
if (isLastValidationCheck) {
|
this.setValidation(errors.concat(...asyncErrors.filter(err => err)));
|
||||||
errors = this.state.errors.concat(asyncErrors.filter(err => err));
|
|
||||||
this.setValidation(errors);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,7 +156,7 @@ export class Input extends React.Component<InputProps, State> {
|
|||||||
|
|
||||||
private setupValidators() {
|
private setupValidators() {
|
||||||
this.validators = conditionalValidators
|
this.validators = conditionalValidators
|
||||||
// add conditional validators if matches input props
|
// add conditional validators if matches input props
|
||||||
.filter(validator => validator.condition(this.props))
|
.filter(validator => validator.condition(this.props))
|
||||||
// add custom validators
|
// add custom validators
|
||||||
.concat(this.props.validators)
|
.concat(this.props.validators)
|
||||||
@ -209,6 +208,19 @@ export class Input extends React.Component<InputProps, State> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@autobind()
|
||||||
|
onKeyDown(evt: React.KeyboardEvent<any>) {
|
||||||
|
const modified = evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey;
|
||||||
|
|
||||||
|
switch (evt.key) {
|
||||||
|
case "Enter":
|
||||||
|
if (this.props.onSubmit && !modified && !evt.repeat) {
|
||||||
|
this.props.onSubmit(this.getValue());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get showMaxLenIndicator() {
|
get showMaxLenIndicator() {
|
||||||
const { maxLength, multiLine } = this.props;
|
const { maxLength, multiLine } = this.props;
|
||||||
return maxLength && multiLine;
|
return maxLength && multiLine;
|
||||||
@ -269,8 +281,10 @@ export class Input extends React.Component<InputProps, State> {
|
|||||||
onFocus: this.onFocus,
|
onFocus: this.onFocus,
|
||||||
onBlur: this.onBlur,
|
onBlur: this.onBlur,
|
||||||
onChange: this.onChange,
|
onChange: this.onChange,
|
||||||
|
onKeyDown: this.onKeyDown,
|
||||||
rows: multiLine ? (rows || 1) : null,
|
rows: multiLine ? (rows || 1) : null,
|
||||||
ref: this.bindRef,
|
ref: this.bindRef,
|
||||||
|
type: "text",
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -8,9 +8,7 @@
|
|||||||
$colorAnimation: colors $duration*4 ease-in-out infinite;
|
$colorAnimation: colors $duration*4 ease-in-out infinite;
|
||||||
|
|
||||||
@mixin spinner-color($color) {
|
@mixin spinner-color($color) {
|
||||||
border-color: $color;
|
border-color: transparent $color;
|
||||||
border-left-color: transparent;
|
|
||||||
border-right-color: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
width: var(--spinner-size);
|
width: var(--spinner-size);
|
||||||
|
|||||||
@ -76,18 +76,14 @@ export class Tooltip extends React.Component<TooltipProps> {
|
|||||||
const { position } = this.props;
|
const { position } = this.props;
|
||||||
const { elem, targetElem } = this;
|
const { elem, targetElem } = this;
|
||||||
|
|
||||||
let allPositions: TooltipPosition[] = [
|
const positionPreference = new Set<TooltipPosition>();
|
||||||
TooltipPosition.RIGHT,
|
if (typeof position !== "undefined") {
|
||||||
TooltipPosition.BOTTOM,
|
positionPreference.add(position);
|
||||||
TooltipPosition.LEFT,
|
|
||||||
TooltipPosition.RIGHT,
|
|
||||||
];
|
|
||||||
if (allPositions.includes(position)) {
|
|
||||||
allPositions = [
|
|
||||||
position, // put first as priority side for positioning
|
|
||||||
...allPositions.filter(pos => pos !== position),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
positionPreference.add(TooltipPosition.RIGHT)
|
||||||
|
.add(TooltipPosition.BOTTOM)
|
||||||
|
.add(TooltipPosition.TOP)
|
||||||
|
.add(TooltipPosition.LEFT)
|
||||||
|
|
||||||
// reset position first and get all possible client-rect area for tooltip element
|
// reset position first and get all possible client-rect area for tooltip element
|
||||||
this.setPosition({ left: 0, top: 0 });
|
this.setPosition({ left: 0, top: 0 });
|
||||||
@ -97,21 +93,21 @@ export class Tooltip extends React.Component<TooltipProps> {
|
|||||||
const { innerWidth: viewportWidth, innerHeight: viewportHeight } = window;
|
const { innerWidth: viewportWidth, innerHeight: viewportHeight } = window;
|
||||||
|
|
||||||
// find proper position
|
// find proper position
|
||||||
this.activePosition = null;
|
for (const pos of positionPreference) {
|
||||||
for (const pos of allPositions) {
|
|
||||||
const { left, top, right, bottom } = this.getPosition(pos, selfBounds, targetBounds)
|
const { left, top, right, bottom } = this.getPosition(pos, selfBounds, targetBounds)
|
||||||
const fitsToWindow = left >= 0 && top >= 0 && right <= viewportWidth && bottom <= viewportHeight;
|
const fitsToWindow = left >= 0 && top >= 0 && right <= viewportWidth && bottom <= viewportHeight;
|
||||||
if (fitsToWindow) {
|
if (fitsToWindow) {
|
||||||
this.activePosition = pos;
|
this.activePosition = pos;
|
||||||
this.setPosition({ top, left });
|
this.setPosition({ top, left });
|
||||||
break;
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!this.activePosition) {
|
|
||||||
const { left, top } = this.getPosition(allPositions[0], selfBounds, targetBounds)
|
const preferedPosition = Array.from(positionPreference)[0];
|
||||||
this.activePosition = allPositions[0];
|
const { left, top } = this.getPosition(preferedPosition, selfBounds, targetBounds)
|
||||||
this.setPosition({ left, top });
|
this.activePosition = preferedPosition;
|
||||||
}
|
this.setPosition({ left, top });
|
||||||
}
|
}
|
||||||
|
|
||||||
protected setPosition(pos: { left: number, top: number }) {
|
protected setPosition(pos: { left: number, top: number }) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user