mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Migrating Vue components to React and stores refactoring (#585)
Signed-off-by: Roman <ixrock@gmail.com> Co-authored-by: Sebastian Malton <sebastian@malton.name> Co-authored-by: Sebastian Malton <smalton@mirantis.com> Co-authored-by: Lauri Nevala <lauri.nevala@gmail.com> Co-authored-by: Alex Andreev <alex.andreev.email@gmail.com>
This commit is contained in:
297 files changed
+8186
-9103
No files matched your search
@@ -0,0 +1,138 @@
|
||||
import path from "path"
|
||||
import Config from "conf"
|
||||
import { Options as ConfOptions } from "conf/dist/source/types"
|
||||
import { app, ipcMain, IpcMainEvent, ipcRenderer, IpcRendererEvent, remote } from "electron"
|
||||
import { action, observable, reaction, runInAction, toJS, when } from "mobx";
|
||||
import Singleton from "./utils/singleton";
|
||||
import { getAppVersion } from "./utils/app-version";
|
||||
import logger from "../main/logger";
|
||||
import { broadcastIpc } from "./ipc";
|
||||
import isEqual from "lodash/isEqual";
|
||||
|
||||
export interface BaseStoreParams<T = any> extends ConfOptions<T> {
|
||||
autoLoad?: boolean;
|
||||
syncEnabled?: boolean;
|
||||
}
|
||||
|
||||
export class BaseStore<T = any> extends Singleton {
|
||||
protected storeConfig: Config<T>;
|
||||
protected syncDisposers: Function[] = [];
|
||||
|
||||
whenLoaded = when(() => this.isLoaded);
|
||||
@observable isLoaded = false;
|
||||
@observable protected data: T;
|
||||
|
||||
protected constructor(protected params: BaseStoreParams) {
|
||||
super();
|
||||
this.params = {
|
||||
autoLoad: false,
|
||||
syncEnabled: true,
|
||||
...params,
|
||||
}
|
||||
this.init();
|
||||
}
|
||||
|
||||
get name() {
|
||||
return path.basename(this.storeConfig.path);
|
||||
}
|
||||
|
||||
get syncChannel() {
|
||||
return `store-sync:${this.name}`
|
||||
}
|
||||
|
||||
protected async init() {
|
||||
if (this.params.autoLoad) {
|
||||
await this.load();
|
||||
}
|
||||
if (this.params.syncEnabled) {
|
||||
await this.whenLoaded;
|
||||
this.enableSync();
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
const { autoLoad, syncEnabled, ...confOptions } = this.params;
|
||||
this.storeConfig = new Config({
|
||||
...confOptions,
|
||||
projectName: "lens",
|
||||
projectVersion: getAppVersion(),
|
||||
cwd: (app || remote.app).getPath("userData"),
|
||||
});
|
||||
logger.info(`[STORE]: LOADED from ${this.storeConfig.path}`);
|
||||
this.fromStore(this.storeConfig.store);
|
||||
this.isLoaded = true;
|
||||
}
|
||||
|
||||
protected async save(model: T) {
|
||||
logger.info(`[STORE]: SAVING ${this.name}`);
|
||||
// todo: update when fixed https://github.com/sindresorhus/conf/issues/114
|
||||
Object.entries(model).forEach(([key, value]) => {
|
||||
this.storeConfig.set(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
enableSync() {
|
||||
this.syncDisposers.push(
|
||||
reaction(() => this.toJSON(), model => this.onModelChange(model)),
|
||||
);
|
||||
if (ipcMain) {
|
||||
const callback = (event: IpcMainEvent, model: T) => {
|
||||
logger.debug(`[STORE]: SYNC ${this.name} from renderer`, { model });
|
||||
this.onSync(model);
|
||||
};
|
||||
ipcMain.on(this.syncChannel, callback);
|
||||
this.syncDisposers.push(() => ipcMain.off(this.syncChannel, callback));
|
||||
}
|
||||
if (ipcRenderer) {
|
||||
const callback = (event: IpcRendererEvent, model: T) => {
|
||||
logger.debug(`[STORE]: SYNC ${this.name} from main`, { model });
|
||||
this.onSync(model);
|
||||
};
|
||||
ipcRenderer.on(this.syncChannel, callback);
|
||||
this.syncDisposers.push(() => ipcRenderer.off(this.syncChannel, callback));
|
||||
}
|
||||
}
|
||||
|
||||
disableSync() {
|
||||
this.syncDisposers.forEach(dispose => dispose());
|
||||
this.syncDisposers.length = 0;
|
||||
}
|
||||
|
||||
protected applyWithoutSync(callback: () => void) {
|
||||
this.disableSync();
|
||||
runInAction(callback);
|
||||
if (this.params.syncEnabled) {
|
||||
this.enableSync();
|
||||
}
|
||||
}
|
||||
|
||||
protected onSync(model: T) {
|
||||
// todo: use "resourceVersion" if merge required (to avoid equality checks => better performance)
|
||||
if (!isEqual(this.toJSON(), model)) {
|
||||
this.fromStore(model);
|
||||
}
|
||||
}
|
||||
|
||||
protected async onModelChange(model: T) {
|
||||
if (ipcMain) {
|
||||
this.save(model); // save config file
|
||||
broadcastIpc({ channel: this.syncChannel, args: [model] }); // broadcast to renderer views
|
||||
}
|
||||
// send "update-request" to main-process
|
||||
if (ipcRenderer) {
|
||||
ipcRenderer.send(this.syncChannel, model);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
protected fromStore(data: T) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
// todo: use "serializr" ?
|
||||
toJSON(): T {
|
||||
return toJS(this.data, {
|
||||
recurseEverything: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createIpcChannel } from "./ipc";
|
||||
import { ClusterId, clusterStore } from "./cluster-store";
|
||||
import { tracker } from "./tracker";
|
||||
|
||||
export const clusterIpc = {
|
||||
init: createIpcChannel({
|
||||
channel: "cluster:init",
|
||||
handle: async (clusterId: ClusterId, frameId: number) => {
|
||||
const cluster = clusterStore.getById(clusterId);
|
||||
if (cluster) {
|
||||
cluster.frameId = frameId; // save cluster's webFrame.routingId to be able to send push-updates
|
||||
return cluster.pushState();
|
||||
}
|
||||
},
|
||||
}),
|
||||
activate: createIpcChannel({
|
||||
channel: "cluster:activate",
|
||||
handle: (clusterId: ClusterId) => {
|
||||
return clusterStore.getById(clusterId)?.activate();
|
||||
},
|
||||
}),
|
||||
|
||||
disconnect: createIpcChannel({
|
||||
channel: "cluster:disconnect",
|
||||
handle: (clusterId: ClusterId) => {
|
||||
tracker.event("cluster", "stop");
|
||||
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)
|
||||
}
|
||||
}),
|
||||
}
|
||||
+168
-99
@@ -1,120 +1,189 @@
|
||||
import ElectronStore from "electron-store"
|
||||
import { Cluster, ClusterBaseInfo } from "../main/cluster";
|
||||
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"
|
||||
import * as version360Beta1 from "../migrations/cluster-store/3.6.0-beta.1"
|
||||
import { getAppVersion } from "./utils/app-version";
|
||||
import type { WorkspaceId } from "./workspace-store";
|
||||
import path from "path";
|
||||
import { app, ipcRenderer, remote } from "electron";
|
||||
import { unlink } from "fs-extra";
|
||||
import { action, computed, observable, toJS } from "mobx";
|
||||
import { BaseStore } from "./base-store";
|
||||
import { Cluster, ClusterState } from "../main/cluster";
|
||||
import migrations from "../migrations/cluster-store"
|
||||
import logger from "../main/logger";
|
||||
import { tracker } from "./tracker";
|
||||
|
||||
export class ClusterStore {
|
||||
private static instance: ClusterStore;
|
||||
public store: ElectronStore;
|
||||
export interface ClusterIconUpload {
|
||||
clusterId: string;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ClusterStoreModel {
|
||||
activeCluster?: ClusterId; // last opened cluster
|
||||
clusters?: ClusterModel[]
|
||||
}
|
||||
|
||||
export type ClusterId = string;
|
||||
|
||||
export interface ClusterModel {
|
||||
id: ClusterId;
|
||||
workspace?: WorkspaceId;
|
||||
contextName?: string;
|
||||
preferences?: ClusterPreferences;
|
||||
kubeConfigPath: string;
|
||||
|
||||
/** @deprecated */
|
||||
kubeConfig?: string; // yaml
|
||||
}
|
||||
|
||||
export interface ClusterPreferences {
|
||||
terminalCWD?: string;
|
||||
clusterName?: string;
|
||||
prometheus?: {
|
||||
namespace: string;
|
||||
service: string;
|
||||
port: number;
|
||||
prefix: string;
|
||||
};
|
||||
prometheusProvider?: {
|
||||
type: string;
|
||||
};
|
||||
icon?: string;
|
||||
httpsProxy?: string;
|
||||
}
|
||||
|
||||
export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
||||
static get iconsDir() {
|
||||
// TODO: remove remote cheat
|
||||
return path.join((app || remote.app).getPath("userData"), "icons");
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
this.store = new ElectronStore({
|
||||
// @ts-ignore
|
||||
// fixme: tests are failed without "projectVersion"
|
||||
projectVersion: getAppVersion(),
|
||||
name: "lens-cluster-store",
|
||||
super({
|
||||
configName: "lens-cluster-store",
|
||||
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,
|
||||
"3.6.0-beta.1": version360Beta1.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)
|
||||
migrations: migrations,
|
||||
});
|
||||
if (ipcRenderer) {
|
||||
ipcRenderer.on("cluster:state", (event, model: ClusterState) => {
|
||||
this.applyWithoutSync(() => {
|
||||
logger.debug(`[CLUSTER-STORE]: received push-state at ${location.host}`, model);
|
||||
this.getById(model.id)?.updateModel(model);
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public removeClustersByWorkspace(workspace: string) {
|
||||
this.getAllClusters().forEach((cluster) => {
|
||||
if (cluster.workspace === workspace) {
|
||||
this.removeCluster(cluster.id)
|
||||
}
|
||||
})
|
||||
@observable activeClusterId: ClusterId;
|
||||
@observable removedClusters = observable.map<ClusterId, Cluster>();
|
||||
@observable clusters = observable.map<ClusterId, Cluster>();
|
||||
|
||||
@computed get activeCluster(): Cluster | null {
|
||||
return this.getById(this.activeClusterId);
|
||||
}
|
||||
|
||||
public getCluster(id: string): Cluster {
|
||||
const cluster = this.getAllClusterObjects().find((cluster) => cluster.id === id)
|
||||
@computed get clustersList(): Cluster[] {
|
||||
return Array.from(this.clusters.values());
|
||||
}
|
||||
|
||||
isActive(id: ClusterId) {
|
||||
return this.activeClusterId === id;
|
||||
}
|
||||
|
||||
setActive(id: ClusterId) {
|
||||
this.activeClusterId = id;
|
||||
}
|
||||
|
||||
hasClusters() {
|
||||
return this.clusters.size > 0;
|
||||
}
|
||||
|
||||
hasContext(name: string) {
|
||||
return this.clustersList.some(cluster => cluster.contextName === name);
|
||||
}
|
||||
|
||||
getById(id: ClusterId): Cluster {
|
||||
return this.clusters.get(id);
|
||||
}
|
||||
|
||||
getByWorkspaceId(workspaceId: string): Cluster[] {
|
||||
return this.clustersList.filter(cluster => cluster.workspace === workspaceId)
|
||||
}
|
||||
|
||||
@action
|
||||
async addCluster(model: ClusterModel, activate = true): Promise<Cluster> {
|
||||
tracker.event("cluster", "add");
|
||||
const cluster = new Cluster(model);
|
||||
this.clusters.set(model.id, cluster);
|
||||
if (activate) this.activeClusterId = model.id;
|
||||
return cluster;
|
||||
}
|
||||
|
||||
@action
|
||||
async removeById(clusterId: ClusterId) {
|
||||
tracker.event("cluster", "remove");
|
||||
const cluster = this.getById(clusterId);
|
||||
if (cluster) {
|
||||
return cluster
|
||||
this.clusters.delete(clusterId);
|
||||
if (this.activeClusterId === clusterId) {
|
||||
this.activeClusterId = null;
|
||||
}
|
||||
unlink(cluster.kubeConfigPath).catch(() => null);
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
public storeCluster(cluster: ClusterBaseInfo) {
|
||||
const clusters = this.getAllClusters();
|
||||
const index = clusters.findIndex((cl) => cl.id === cluster.id)
|
||||
const storable = {
|
||||
id: cluster.id,
|
||||
kubeConfigPath: cluster.kubeConfigPath,
|
||||
contextName: cluster.contextName,
|
||||
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)
|
||||
@action
|
||||
removeByWorkspaceId(workspaceId: string) {
|
||||
this.getByWorkspaceId(workspaceId).forEach(cluster => {
|
||||
this.removeById(cluster.id)
|
||||
})
|
||||
}
|
||||
|
||||
public reloadCluster(cluster: ClusterBaseInfo): void {
|
||||
const storedCluster = this.getCluster(cluster.id);
|
||||
if (storedCluster) {
|
||||
cluster.kubeConfigPath = storedCluster.kubeConfigPath
|
||||
cluster.contextName = storedCluster.contextName
|
||||
cluster.preferences = storedCluster.preferences
|
||||
cluster.workspace = storedCluster.workspace
|
||||
}
|
||||
@action
|
||||
protected fromStore({ activeCluster, clusters = [] }: ClusterStoreModel = {}) {
|
||||
const currentClusters = this.clusters.toJS();
|
||||
const newClusters = new Map<ClusterId, Cluster>();
|
||||
const removedClusters = new Map<ClusterId, Cluster>();
|
||||
|
||||
// update new clusters
|
||||
clusters.forEach(clusterModel => {
|
||||
let cluster = currentClusters.get(clusterModel.id);
|
||||
if (cluster) {
|
||||
cluster.updateModel(clusterModel);
|
||||
} else {
|
||||
cluster = new Cluster(clusterModel);
|
||||
}
|
||||
newClusters.set(clusterModel.id, cluster);
|
||||
});
|
||||
|
||||
// update removed clusters
|
||||
currentClusters.forEach(cluster => {
|
||||
if (!newClusters.has(cluster.id)) {
|
||||
removedClusters.set(cluster.id, cluster);
|
||||
}
|
||||
});
|
||||
|
||||
this.activeClusterId = newClusters.has(activeCluster) ? activeCluster : null;
|
||||
this.clusters.replace(newClusters);
|
||||
this.removedClusters.replace(removedClusters);
|
||||
}
|
||||
|
||||
static getInstance(): ClusterStore {
|
||||
if (!ClusterStore.instance) {
|
||||
ClusterStore.instance = new ClusterStore();
|
||||
}
|
||||
return ClusterStore.instance;
|
||||
}
|
||||
|
||||
static resetInstance() {
|
||||
ClusterStore.instance = null
|
||||
toJSON(): ClusterStoreModel {
|
||||
return toJS({
|
||||
activeCluster: this.activeClusterId,
|
||||
clusters: this.clustersList.map(cluster => cluster.toJSON()),
|
||||
}, {
|
||||
recurseEverything: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const clusterStore: ClusterStore = ClusterStore.getInstance();
|
||||
export const clusterStore = ClusterStore.getInstance<ClusterStore>();
|
||||
|
||||
export function getHostedClusterId(): ClusterId {
|
||||
const clusterHost = location.hostname.match(/^(.*?)\.localhost/);
|
||||
if (clusterHost) {
|
||||
return clusterHost[1]
|
||||
}
|
||||
}
|
||||
|
||||
export function getHostedCluster(): Cluster {
|
||||
return clusterStore.getById(getHostedClusterId());
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
import mockFs from "mock-fs"
|
||||
import yaml from "js-yaml"
|
||||
import * as fs from "fs"
|
||||
import { ClusterStore } from "./cluster-store";
|
||||
import { Cluster } from "../main/cluster";
|
||||
|
||||
jest.mock("electron", () => {
|
||||
return {
|
||||
app: {
|
||||
getVersion: () => '99.99.99',
|
||||
getPath: () => 'tmp',
|
||||
getLocale: () => 'en'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Console.log needs to be called before fs-mocks, see https://github.com/tschaub/mock-fs/issues/234
|
||||
console.log("");
|
||||
|
||||
describe("for an empty config", () => {
|
||||
beforeEach(() => {
|
||||
ClusterStore.resetInstance()
|
||||
const mockOpts = {
|
||||
'tmp': {
|
||||
'lens-cluster-store.json': JSON.stringify({})
|
||||
}
|
||||
}
|
||||
mockFs(mockOpts)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
it("allows to store and retrieve a cluster", async () => {
|
||||
const cluster = new Cluster({
|
||||
id: 'foo',
|
||||
kubeConfigPath: 'kubeconfig',
|
||||
contextName: "foo",
|
||||
preferences: {
|
||||
terminalCWD: '/tmp',
|
||||
icon: 'path to icon'
|
||||
}
|
||||
})
|
||||
const clusterStore = ClusterStore.getInstance()
|
||||
clusterStore.storeCluster(cluster);
|
||||
const storedCluster = clusterStore.getCluster(cluster.id);
|
||||
expect(storedCluster.kubeConfigPath).toBe(cluster.kubeConfigPath)
|
||||
expect(storedCluster.contextName).toBe(cluster.contextName)
|
||||
expect(storedCluster.preferences.icon).toBe(cluster.preferences.icon)
|
||||
expect(storedCluster.preferences.terminalCWD).toBe(cluster.preferences.terminalCWD)
|
||||
expect(storedCluster.id).toBe(cluster.id)
|
||||
})
|
||||
|
||||
it("allows to delete a cluster", async () => {
|
||||
const cluster = new Cluster({
|
||||
id: 'foofoo',
|
||||
kubeConfigPath: 'kubeconfig',
|
||||
contextName: "foo",
|
||||
preferences: {
|
||||
terminalCWD: '/tmp'
|
||||
}
|
||||
})
|
||||
const clusterStore = ClusterStore.getInstance()
|
||||
|
||||
clusterStore.storeCluster(cluster);
|
||||
|
||||
const storedCluster = clusterStore.getCluster(cluster.id);
|
||||
expect(storedCluster.id).toBe(cluster.id)
|
||||
|
||||
clusterStore.removeCluster(cluster.id);
|
||||
|
||||
expect(clusterStore.getCluster(cluster.id)).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe("for a config with existing clusters", () => {
|
||||
beforeEach(() => {
|
||||
ClusterStore.resetInstance()
|
||||
const mockOpts = {
|
||||
'tmp': {
|
||||
'lens-cluster-store.json': JSON.stringify({
|
||||
__internal__: {
|
||||
migrations: {
|
||||
version: "99.99.99"
|
||||
}
|
||||
},
|
||||
clusters: [
|
||||
{
|
||||
id: 'cluster1',
|
||||
kubeConfigPath: 'foo',
|
||||
preferences: { terminalCWD: '/foo' }
|
||||
},
|
||||
{
|
||||
id: 'cluster2',
|
||||
kubeConfigPath: 'foo2',
|
||||
preferences: { terminalCWD: '/foo2' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
mockFs(mockOpts)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
it("allows to retrieve a cluster", async () => {
|
||||
const clusterStore = ClusterStore.getInstance()
|
||||
const storedCluster = clusterStore.getCluster('cluster1')
|
||||
expect(storedCluster.kubeConfigPath).toBe('foo')
|
||||
expect(storedCluster.preferences.terminalCWD).toBe('/foo')
|
||||
expect(storedCluster.id).toBe('cluster1')
|
||||
|
||||
const storedCluster2 = clusterStore.getCluster('cluster2')
|
||||
expect(storedCluster2.kubeConfigPath).toBe('foo2')
|
||||
expect(storedCluster2.preferences.terminalCWD).toBe('/foo2')
|
||||
expect(storedCluster2.id).toBe('cluster2')
|
||||
})
|
||||
|
||||
it("allows to delete a cluster", async () => {
|
||||
const clusterStore = ClusterStore.getInstance()
|
||||
|
||||
clusterStore.removeCluster('cluster2')
|
||||
|
||||
// Verify the other cluster still exists:
|
||||
const storedCluster = clusterStore.getCluster('cluster1')
|
||||
expect(storedCluster.id).toBe('cluster1')
|
||||
|
||||
const storedCluster2 = clusterStore.getCluster('cluster2')
|
||||
expect(storedCluster2).toBe(null)
|
||||
})
|
||||
|
||||
it("allows to reload a cluster in-place", async () => {
|
||||
const cluster = new Cluster({
|
||||
id: 'cluster1',
|
||||
kubeConfigPath: 'kubeconfig string',
|
||||
contextName: "foo",
|
||||
preferences: {
|
||||
terminalCWD: '/tmp'
|
||||
}
|
||||
})
|
||||
|
||||
const clusterStore = ClusterStore.getInstance()
|
||||
clusterStore.reloadCluster(cluster)
|
||||
|
||||
expect(cluster.kubeConfigPath).toBe('foo')
|
||||
expect(cluster.preferences.terminalCWD).toBe('/foo')
|
||||
expect(cluster.id).toBe('cluster1')
|
||||
})
|
||||
|
||||
it("allows getting all the clusters", async () => {
|
||||
const clusterStore = ClusterStore.getInstance()
|
||||
const storedClusters = clusterStore.getAllClusters()
|
||||
|
||||
expect(storedClusters[0].id).toBe('cluster1')
|
||||
expect(storedClusters[0].preferences.terminalCWD).toBe('/foo')
|
||||
expect(storedClusters[0].kubeConfigPath).toBe('foo')
|
||||
|
||||
expect(storedClusters[1].id).toBe('cluster2')
|
||||
expect(storedClusters[1].preferences.terminalCWD).toBe('/foo2')
|
||||
expect(storedClusters[1].kubeConfigPath).toBe('foo2')
|
||||
})
|
||||
|
||||
it("allows storing the clusters in a different order", async () => {
|
||||
const clusterStore = ClusterStore.getInstance()
|
||||
const storedClusters = clusterStore.getAllClusters()
|
||||
|
||||
const reorderedClusters = [storedClusters[1], storedClusters[0]]
|
||||
clusterStore.storeClusters(reorderedClusters)
|
||||
const storedClusters2 = clusterStore.getAllClusters()
|
||||
|
||||
expect(storedClusters2[0].id).toBe('cluster2')
|
||||
expect(storedClusters2[1].id).toBe('cluster1')
|
||||
})
|
||||
})
|
||||
|
||||
describe("for a pre 2.0 config with an existing cluster", () => {
|
||||
beforeEach(() => {
|
||||
ClusterStore.resetInstance()
|
||||
const mockOpts = {
|
||||
'tmp': {
|
||||
'lens-cluster-store.json': JSON.stringify({
|
||||
__internal__: {
|
||||
migrations: {
|
||||
version: "1.0.0"
|
||||
}
|
||||
},
|
||||
cluster1: 'kubeconfig content'
|
||||
})
|
||||
}
|
||||
}
|
||||
mockFs(mockOpts)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
it("migrates to modern format with kubeconfig under a key", async () => {
|
||||
const clusterStore = ClusterStore.getInstance()
|
||||
const storedCluster = clusterStore.store.get('clusters')[0]
|
||||
expect(storedCluster.kubeConfigPath).toBe(`tmp/kubeconfigs/${storedCluster.id}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe("for a pre 2.4.1 config with an existing cluster", () => {
|
||||
beforeEach(() => {
|
||||
ClusterStore.resetInstance()
|
||||
const mockOpts = {
|
||||
'tmp': {
|
||||
'lens-cluster-store.json': JSON.stringify({
|
||||
__internal__: {
|
||||
migrations: {
|
||||
version: "2.0.0-beta.2"
|
||||
}
|
||||
},
|
||||
cluster1: {
|
||||
kubeConfig: 'foo',
|
||||
online: true,
|
||||
accessible: false,
|
||||
failureReason: 'user error'
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
mockFs(mockOpts)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
it("migrates to modern format throwing out the state related data", async () => {
|
||||
const clusterStore = ClusterStore.getInstance()
|
||||
const storedClusterData = clusterStore.store.get('clusters')[0]
|
||||
expect(storedClusterData.hasOwnProperty('online')).toBe(false)
|
||||
expect(storedClusterData.hasOwnProperty('accessible')).toBe(false)
|
||||
expect(storedClusterData.hasOwnProperty('failureReason')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("for a pre 2.6.0 config with a cluster that has arrays in auth config", () => {
|
||||
beforeEach(() => {
|
||||
ClusterStore.resetInstance()
|
||||
const mockOpts = {
|
||||
'tmp': {
|
||||
'lens-cluster-store.json': JSON.stringify({
|
||||
__internal__: {
|
||||
migrations: {
|
||||
version: "2.4.1"
|
||||
}
|
||||
},
|
||||
cluster1: {
|
||||
kubeConfig: "apiVersion: v1\nclusters:\n- cluster:\n server: https://10.211.55.6:8443\n name: minikube\ncontexts:\n- context:\n cluster: minikube\n user: minikube\n name: minikube\ncurrent-context: minikube\nkind: Config\npreferences: {}\nusers:\n- name: minikube\n user:\n client-certificate: /Users/kimmo/.minikube/client.crt\n client-key: /Users/kimmo/.minikube/client.key\n auth-provider:\n config:\n access-token:\n - should be string\n expiry:\n - should be string\n"
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
mockFs(mockOpts)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
it("replaces array format access token and expiry into string", async () => {
|
||||
const clusterStore = ClusterStore.getInstance()
|
||||
const storedClusterData = clusterStore.store.get('clusters')[0]
|
||||
const kc = yaml.safeLoad(fs.readFileSync(storedClusterData.kubeConfigPath).toString())
|
||||
expect(kc.users[0].user['auth-provider'].config['access-token']).toBe("should be string")
|
||||
expect(kc.users[0].user['auth-provider'].config['expiry']).toBe("should be string")
|
||||
expect(storedClusterData.contextName).toBe("minikube")
|
||||
})
|
||||
})
|
||||
|
||||
describe("for a pre 2.6.0 config with a cluster icon", () => {
|
||||
beforeEach(() => {
|
||||
ClusterStore.resetInstance()
|
||||
const mockOpts = {
|
||||
'tmp': {
|
||||
'lens-cluster-store.json': JSON.stringify({
|
||||
__internal__: {
|
||||
migrations: {
|
||||
version: "2.4.1"
|
||||
}
|
||||
},
|
||||
cluster1: {
|
||||
kubeConfig: "foo",
|
||||
icon: "icon path",
|
||||
preferences: {
|
||||
terminalCWD: "/tmp"
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
mockFs(mockOpts)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
it("moves the icon into preferences", async () => {
|
||||
const clusterStore = ClusterStore.getInstance()
|
||||
const storedClusterData = clusterStore.store.get('clusters')[0]
|
||||
expect(storedClusterData.hasOwnProperty('icon')).toBe(false)
|
||||
expect(storedClusterData.preferences.hasOwnProperty('icon')).toBe(true)
|
||||
expect(storedClusterData.preferences.icon).toBe("icon path")
|
||||
})
|
||||
})
|
||||
|
||||
describe("for a pre 2.7.0-beta.0 config without a workspace", () => {
|
||||
beforeEach(() => {
|
||||
ClusterStore.resetInstance()
|
||||
const mockOpts = {
|
||||
'tmp': {
|
||||
'lens-cluster-store.json': JSON.stringify({
|
||||
__internal__: {
|
||||
migrations: {
|
||||
version: "2.6.6"
|
||||
}
|
||||
},
|
||||
cluster1: {
|
||||
kubeConfig: "foo",
|
||||
icon: "icon path",
|
||||
preferences: {
|
||||
terminalCWD: "/tmp"
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
mockFs(mockOpts)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
it("adds cluster to default workspace", async () => {
|
||||
const clusterStore = ClusterStore.getInstance()
|
||||
const storedClusterData = clusterStore.store.get("clusters")[0]
|
||||
expect(storedClusterData.workspace).toBe('default')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,333 @@
|
||||
import fs from "fs";
|
||||
import mockFs from "mock-fs";
|
||||
import yaml from "js-yaml";
|
||||
import { Cluster } from "../main/cluster";
|
||||
import { ClusterStore } from "./cluster-store";
|
||||
import { workspaceStore } from "./workspace-store";
|
||||
import { saveConfigToAppFiles } from "./kube-helpers";
|
||||
|
||||
let clusterStore: ClusterStore;
|
||||
|
||||
describe("empty config", () => {
|
||||
beforeAll(() => {
|
||||
ClusterStore.resetInstance();
|
||||
const mockOpts = {
|
||||
'tmp': {
|
||||
'lens-cluster-store.json': JSON.stringify({})
|
||||
}
|
||||
}
|
||||
mockFs(mockOpts);
|
||||
clusterStore = ClusterStore.getInstance<ClusterStore>();
|
||||
return clusterStore.load();
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
mockFs.restore();
|
||||
})
|
||||
|
||||
it("adds new cluster to store", async () => {
|
||||
const cluster = new Cluster({
|
||||
id: "foo",
|
||||
preferences: {
|
||||
terminalCWD: "/tmp",
|
||||
icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5",
|
||||
clusterName: "minikube"
|
||||
},
|
||||
kubeConfigPath: saveConfigToAppFiles("foo", "fancy foo config"),
|
||||
workspace: workspaceStore.currentWorkspaceId
|
||||
});
|
||||
clusterStore.addCluster(cluster);
|
||||
const storedCluster = clusterStore.getById(cluster.id);
|
||||
expect(storedCluster.id).toBe(cluster.id);
|
||||
expect(storedCluster.preferences.terminalCWD).toBe(cluster.preferences.terminalCWD);
|
||||
expect(storedCluster.preferences.icon).toBe(cluster.preferences.icon);
|
||||
})
|
||||
|
||||
it("adds cluster to default workspace", () => {
|
||||
const storedCluster = clusterStore.getById("foo");
|
||||
expect(storedCluster.workspace).toBe("default");
|
||||
})
|
||||
|
||||
it("check if store can contain multiple clusters", () => {
|
||||
const prodCluster = new Cluster({
|
||||
id: "prod",
|
||||
preferences: {
|
||||
clusterName: "prod"
|
||||
},
|
||||
kubeConfigPath: saveConfigToAppFiles("prod", "fancy config"),
|
||||
workspace: "workstation"
|
||||
});
|
||||
const devCluster = new Cluster({
|
||||
id: "dev",
|
||||
preferences: {
|
||||
clusterName: "dev"
|
||||
},
|
||||
kubeConfigPath: saveConfigToAppFiles("dev", "fancy config"),
|
||||
workspace: "workstation"
|
||||
});
|
||||
clusterStore.addCluster(prodCluster);
|
||||
clusterStore.addCluster(devCluster);
|
||||
expect(clusterStore.hasClusters()).toBeTruthy();
|
||||
expect(clusterStore.clusters.size).toBe(3);
|
||||
});
|
||||
|
||||
it("gets clusters by workspaces", () => {
|
||||
const wsClusters = clusterStore.getByWorkspaceId("workstation");
|
||||
const defaultClusters = clusterStore.getByWorkspaceId("default");
|
||||
expect(defaultClusters.length).toBe(1);
|
||||
expect(wsClusters.length).toBe(2);
|
||||
expect(wsClusters[0].id).toBe("prod");
|
||||
expect(wsClusters[1].id).toBe("dev");
|
||||
})
|
||||
|
||||
it("checks if last added cluster becomes active", () => {
|
||||
expect(clusterStore.activeCluster.id).toBe("dev");
|
||||
})
|
||||
|
||||
it("sets active cluster", () => {
|
||||
clusterStore.setActive("foo");
|
||||
expect(clusterStore.activeCluster.id).toBe("foo");
|
||||
})
|
||||
|
||||
it("check if cluster's kubeconfig file saved", () => {
|
||||
const file = saveConfigToAppFiles("boo", "kubeconfig");
|
||||
expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig");
|
||||
})
|
||||
|
||||
it("removes cluster from store", async () => {
|
||||
await clusterStore.removeById("foo");
|
||||
expect(clusterStore.getById("foo")).toBeUndefined();
|
||||
})
|
||||
})
|
||||
|
||||
describe("config with existing clusters", () => {
|
||||
beforeEach(() => {
|
||||
ClusterStore.resetInstance();
|
||||
const mockOpts = {
|
||||
'tmp': {
|
||||
'lens-cluster-store.json': JSON.stringify({
|
||||
__internal__: {
|
||||
migrations: {
|
||||
version: "99.99.99"
|
||||
}
|
||||
},
|
||||
clusters: [
|
||||
{
|
||||
id: 'cluster1',
|
||||
kubeConfig: 'foo',
|
||||
preferences: { terminalCWD: '/foo' }
|
||||
},
|
||||
{
|
||||
id: 'cluster2',
|
||||
kubeConfig: 'foo2',
|
||||
preferences: { terminalCWD: '/foo2' }
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
mockFs(mockOpts);
|
||||
clusterStore = ClusterStore.getInstance<ClusterStore>();
|
||||
return clusterStore.load();
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
})
|
||||
|
||||
it("allows to retrieve a cluster", () => {
|
||||
const storedCluster = clusterStore.getById('cluster1');
|
||||
expect(storedCluster.id).toBe('cluster1');
|
||||
expect(storedCluster.preferences.terminalCWD).toBe('/foo');
|
||||
})
|
||||
|
||||
it("allows to delete a cluster", () => {
|
||||
clusterStore.removeById('cluster2');
|
||||
const storedCluster = clusterStore.getById('cluster1');
|
||||
expect(storedCluster).toBeTruthy();
|
||||
const storedCluster2 = clusterStore.getById('cluster2');
|
||||
expect(storedCluster2).toBeUndefined();
|
||||
})
|
||||
|
||||
it("allows getting all of the clusters", async () => {
|
||||
const storedClusters = clusterStore.clustersList;
|
||||
expect(storedClusters[0].id).toBe('cluster1')
|
||||
expect(storedClusters[0].preferences.terminalCWD).toBe('/foo')
|
||||
expect(storedClusters[1].id).toBe('cluster2')
|
||||
expect(storedClusters[1].preferences.terminalCWD).toBe('/foo2')
|
||||
})
|
||||
})
|
||||
|
||||
describe("pre 2.0 config with an existing cluster", () => {
|
||||
beforeEach(() => {
|
||||
ClusterStore.resetInstance();
|
||||
const mockOpts = {
|
||||
'tmp': {
|
||||
'lens-cluster-store.json': JSON.stringify({
|
||||
__internal__: {
|
||||
migrations: {
|
||||
version: "1.0.0"
|
||||
}
|
||||
},
|
||||
cluster1: 'kubeconfig content'
|
||||
})
|
||||
}
|
||||
};
|
||||
mockFs(mockOpts);
|
||||
clusterStore = ClusterStore.getInstance<ClusterStore>();
|
||||
return clusterStore.load();
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
})
|
||||
|
||||
it("migrates to modern format with kubeconfig in a file", async () => {
|
||||
const config = clusterStore.clustersList[0].kubeConfigPath;
|
||||
expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content");
|
||||
})
|
||||
})
|
||||
|
||||
describe("pre 2.6.0 config with a cluster that has arrays in auth config", () => {
|
||||
beforeEach(() => {
|
||||
ClusterStore.resetInstance();
|
||||
const mockOpts = {
|
||||
'tmp': {
|
||||
'lens-cluster-store.json': JSON.stringify({
|
||||
__internal__: {
|
||||
migrations: {
|
||||
version: "2.4.1"
|
||||
}
|
||||
},
|
||||
cluster1: {
|
||||
kubeConfig: "apiVersion: v1\nclusters:\n- cluster:\n server: https://10.211.55.6:8443\n name: minikube\ncontexts:\n- context:\n cluster: minikube\n user: minikube\n name: minikube\ncurrent-context: minikube\nkind: Config\npreferences: {}\nusers:\n- name: minikube\n user:\n client-certificate: /Users/kimmo/.minikube/client.crt\n client-key: /Users/kimmo/.minikube/client.key\n auth-provider:\n config:\n access-token:\n - should be string\n expiry:\n - should be string\n"
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
mockFs(mockOpts);
|
||||
clusterStore = ClusterStore.getInstance<ClusterStore>();
|
||||
return clusterStore.load();
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
})
|
||||
|
||||
it("replaces array format access token and expiry into string", async () => {
|
||||
const file = clusterStore.clustersList[0].kubeConfigPath;
|
||||
const config = fs.readFileSync(file, "utf8");
|
||||
const kc = yaml.safeLoad(config);
|
||||
expect(kc.users[0].user['auth-provider'].config['access-token']).toBe("should be string");
|
||||
expect(kc.users[0].user['auth-provider'].config['expiry']).toBe("should be string");
|
||||
})
|
||||
})
|
||||
|
||||
describe("pre 2.6.0 config with a cluster icon", () => {
|
||||
beforeEach(() => {
|
||||
ClusterStore.resetInstance();
|
||||
const mockOpts = {
|
||||
'tmp': {
|
||||
'lens-cluster-store.json': JSON.stringify({
|
||||
__internal__: {
|
||||
migrations: {
|
||||
version: "2.4.1"
|
||||
}
|
||||
},
|
||||
cluster1: {
|
||||
kubeConfig: "foo",
|
||||
icon: "icon path",
|
||||
preferences: {
|
||||
terminalCWD: "/tmp"
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
mockFs(mockOpts);
|
||||
clusterStore = ClusterStore.getInstance<ClusterStore>();
|
||||
return clusterStore.load();
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
})
|
||||
|
||||
it("moves the icon into preferences", async () => {
|
||||
const storedClusterData = clusterStore.clustersList[0];
|
||||
expect(storedClusterData.hasOwnProperty('icon')).toBe(false);
|
||||
expect(storedClusterData.preferences.hasOwnProperty('icon')).toBe(true);
|
||||
expect(storedClusterData.preferences.icon).toBe("icon path");
|
||||
})
|
||||
})
|
||||
|
||||
describe("for a pre 2.7.0-beta.0 config without a workspace", () => {
|
||||
beforeEach(() => {
|
||||
ClusterStore.resetInstance();
|
||||
const mockOpts = {
|
||||
'tmp': {
|
||||
'lens-cluster-store.json': JSON.stringify({
|
||||
__internal__: {
|
||||
migrations: {
|
||||
version: "2.6.6"
|
||||
}
|
||||
},
|
||||
cluster1: {
|
||||
kubeConfig: "foo",
|
||||
icon: "icon path",
|
||||
preferences: {
|
||||
terminalCWD: "/tmp"
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
mockFs(mockOpts);
|
||||
clusterStore = ClusterStore.getInstance<ClusterStore>();
|
||||
return clusterStore.load();
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
})
|
||||
|
||||
it("adds cluster to default workspace", async () => {
|
||||
const storedClusterData = clusterStore.clustersList[0];
|
||||
expect(storedClusterData.workspace).toBe('default');
|
||||
})
|
||||
})
|
||||
|
||||
describe("pre 3.6.0-beta.1 config with an existing cluster", () => {
|
||||
beforeEach(() => {
|
||||
ClusterStore.resetInstance();
|
||||
const mockOpts = {
|
||||
'tmp': {
|
||||
'lens-cluster-store.json': JSON.stringify({
|
||||
__internal__: {
|
||||
migrations: {
|
||||
version: "2.7.0"
|
||||
}
|
||||
},
|
||||
clusters: [
|
||||
{
|
||||
id: 'cluster1',
|
||||
kubeConfig: 'kubeconfig content'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
};
|
||||
mockFs(mockOpts);
|
||||
clusterStore = ClusterStore.getInstance<ClusterStore>();
|
||||
return clusterStore.load();
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
})
|
||||
|
||||
it("migrates to modern format with kubeconfig in a file", async () => {
|
||||
const config = clusterStore.clustersList[0].kubeConfigPath;
|
||||
expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content");
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
// Inter-protocol communications (main <-> renderer)
|
||||
// https://www.electronjs.org/docs/api/ipc-main
|
||||
// https://www.electronjs.org/docs/api/ipc-renderer
|
||||
|
||||
import { ipcMain, ipcRenderer, WebContents, webContents } from "electron"
|
||||
import logger from "../main/logger";
|
||||
|
||||
export type IpcChannel = string;
|
||||
|
||||
export interface IpcChannelOptions {
|
||||
channel: IpcChannel; // main <-> renderer communication channel name
|
||||
handle?: (...args: any[]) => Promise<any> | any; // message handler
|
||||
autoBind?: boolean; // auto-bind message handler in main-process, default: true
|
||||
timeout?: number; // timeout for waiting response from the sender
|
||||
once?: boolean; // one-time event
|
||||
}
|
||||
|
||||
export function createIpcChannel({ autoBind = true, once, timeout = 0, handle, channel }: IpcChannelOptions) {
|
||||
const ipcChannel = {
|
||||
channel: channel,
|
||||
handleInMain: () => {
|
||||
logger.info(`[IPC]: setup channel "${channel}"`);
|
||||
const ipcHandler = once ? ipcMain.handleOnce : ipcMain.handle;
|
||||
ipcHandler(channel, async (event, ...args) => {
|
||||
let timerId: any;
|
||||
try {
|
||||
if (timeout > 0) {
|
||||
timerId = setTimeout(() => {
|
||||
throw new Error(`[IPC]: response timeout in ${timeout}ms`)
|
||||
}, timeout);
|
||||
}
|
||||
return await handle(...args); // todo: maybe exec in separate thread/worker
|
||||
} catch (error) {
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timerId);
|
||||
}
|
||||
})
|
||||
},
|
||||
removeHandler() {
|
||||
ipcMain.removeHandler(channel);
|
||||
},
|
||||
invokeFromRenderer: async <T>(...args: any[]): Promise<T> => {
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
},
|
||||
}
|
||||
if (autoBind && ipcMain) {
|
||||
ipcChannel.handleInMain();
|
||||
}
|
||||
return ipcChannel;
|
||||
}
|
||||
|
||||
export interface IpcBroadcastParams<A extends any[] = any> {
|
||||
channel: IpcChannel
|
||||
webContentId?: number; // send to single webContents view
|
||||
frameId?: number; // send to inner frame of webContents
|
||||
filter?: (webContent: WebContents) => boolean
|
||||
timeout?: number; // todo: add support
|
||||
args?: A;
|
||||
}
|
||||
|
||||
export function broadcastIpc({ channel, frameId, webContentId, filter, args = [] }: IpcBroadcastParams) {
|
||||
const singleView = webContentId ? webContents.fromId(webContentId) : null;
|
||||
let views = singleView ? [singleView] : webContents.getAllWebContents();
|
||||
if (filter) {
|
||||
views = views.filter(filter);
|
||||
}
|
||||
views.forEach(webContent => {
|
||||
const type = webContent.getType();
|
||||
logger.debug(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args });
|
||||
webContent.send(channel, ...args);
|
||||
if (frameId) {
|
||||
webContent.sendToFrame(frameId, channel, ...args)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { app, remote } from "electron";
|
||||
import { KubeConfig, V1Node, V1Pod } from "@kubernetes/client-node"
|
||||
import fse, { ensureDirSync, readFile, writeFileSync } from "fs-extra";
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import yaml from "js-yaml"
|
||||
import logger from "../main/logger";
|
||||
|
||||
function resolveTilde(filePath: string) {
|
||||
if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) {
|
||||
return filePath.replace("~", os.homedir());
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function loadConfig(pathOrContent?: string): KubeConfig {
|
||||
const kc = new KubeConfig();
|
||||
|
||||
if (fse.pathExistsSync(pathOrContent)) {
|
||||
kc.loadFromFile(path.resolve(resolveTilde(pathOrContent)));
|
||||
} else {
|
||||
kc.loadFromString(pathOrContent);
|
||||
}
|
||||
|
||||
return kc
|
||||
}
|
||||
|
||||
/**
|
||||
* KubeConfig is valid when there's at least one of each defined:
|
||||
* - User
|
||||
* - Cluster
|
||||
* - Context
|
||||
* @param config KubeConfig to check
|
||||
*/
|
||||
export function validateConfig(config: KubeConfig | string): KubeConfig {
|
||||
if (typeof config == "string") {
|
||||
config = loadConfig(config);
|
||||
}
|
||||
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 config
|
||||
}
|
||||
|
||||
/**
|
||||
* Breaks kube config into several configs. Each context as it own KubeConfig object
|
||||
*/
|
||||
export function splitConfig(kubeConfig: KubeConfig): KubeConfig[] {
|
||||
const configs: KubeConfig[] = []
|
||||
if (!kubeConfig.contexts) {
|
||||
return configs;
|
||||
}
|
||||
kubeConfig.contexts.forEach(ctx => {
|
||||
const kc = new 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;
|
||||
}
|
||||
|
||||
export function dumpConfigYaml(kubeConfig: Partial<KubeConfig>): string {
|
||||
const config = {
|
||||
apiVersion: "v1",
|
||||
kind: "Config",
|
||||
preferences: {},
|
||||
'current-context': kubeConfig.currentContext,
|
||||
clusters: kubeConfig.clusters.map(cluster => {
|
||||
return {
|
||||
name: cluster.name,
|
||||
cluster: {
|
||||
'certificate-authority-data': cluster.caData,
|
||||
'certificate-authority': cluster.caFile,
|
||||
server: cluster.server,
|
||||
'insecure-skip-tls-verify': cluster.skipTLSVerify
|
||||
}
|
||||
}
|
||||
}),
|
||||
contexts: kubeConfig.contexts.map(context => {
|
||||
return {
|
||||
name: context.name,
|
||||
context: {
|
||||
cluster: context.cluster,
|
||||
user: context.user,
|
||||
namespace: context.namespace
|
||||
}
|
||||
}
|
||||
}),
|
||||
users: kubeConfig.users.map(user => {
|
||||
return {
|
||||
name: user.name,
|
||||
user: {
|
||||
'client-certificate-data': user.certData,
|
||||
'client-certificate': user.certFile,
|
||||
'client-key-data': user.keyData,
|
||||
'client-key': user.keyFile,
|
||||
'auth-provider': user.authProvider,
|
||||
exec: user.exec,
|
||||
token: user.token,
|
||||
username: user.username,
|
||||
password: user.password
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.debug("Dumping KubeConfig:", config);
|
||||
|
||||
// skipInvalid: true makes dump ignore undefined values
|
||||
return yaml.safeDump(config, { skipInvalid: true });
|
||||
}
|
||||
|
||||
export function podHasIssues(pod: 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
|
||||
)
|
||||
}
|
||||
|
||||
export function getNodeWarningConditions(node: V1Node) {
|
||||
return node.status.conditions.filter(c =>
|
||||
c.status.toLowerCase() === "true" && c.type !== "Ready" && c.type !== "HostUpgrades"
|
||||
)
|
||||
}
|
||||
|
||||
// Write kubeconfigs to "embedded" store, i.e. "/Users/ixrock/Library/Application Support/Lens/kubeconfigs"
|
||||
export function saveConfigToAppFiles(clusterId: string, kubeConfig: KubeConfig | string): string {
|
||||
const userData = (app || remote.app).getPath("userData");
|
||||
const kubeConfigFile = path.join(userData, `kubeconfigs/${clusterId}`)
|
||||
const kubeConfigContents = typeof kubeConfig == "string" ? kubeConfig : dumpConfigYaml(kubeConfig);
|
||||
|
||||
ensureDirSync(path.dirname(kubeConfigFile));
|
||||
writeFileSync(kubeConfigFile, kubeConfigContents);
|
||||
return kubeConfigFile;
|
||||
}
|
||||
|
||||
export async function getKubeConfigLocal(): Promise<string> {
|
||||
try {
|
||||
const configFile = path.join(os.homedir(), '.kube', 'config');
|
||||
const file = await readFile(configFile, "utf8");
|
||||
const obj = yaml.safeLoad(file);
|
||||
if (obj.contexts) {
|
||||
obj.contexts = obj.contexts.filter((ctx: any) => ctx?.context?.cluster && ctx?.name)
|
||||
}
|
||||
return yaml.safeDump(obj);
|
||||
} catch (err) {
|
||||
logger.debug(`Cannot read local kube-config: ${err}`)
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getHostedCluster } from "./cluster-store";
|
||||
|
||||
export type KubeResource =
|
||||
"namespaces" | "nodes" | "events" | "resourcequotas" |
|
||||
"services" | "secrets" | "configmaps" | "ingresses" | "networkpolicies" | "persistentvolumes" | "storageclasses" |
|
||||
"pods" | "daemonsets" | "deployments" | "statefulsets" | "replicasets" | "jobs" | "cronjobs" |
|
||||
"endpoints" | "customresourcedefinitions" | "horizontalpodautoscalers" | "podsecuritypolicies"
|
||||
|
||||
export interface KubeApiResource {
|
||||
resource: KubeResource; // valid resource name
|
||||
group?: string; // api-group
|
||||
}
|
||||
|
||||
// TODO: auto-populate all resources dynamically (see: kubectl api-resources -o=wide -v=7)
|
||||
export const apiResources: KubeApiResource[] = [
|
||||
{ resource: "configmaps" },
|
||||
{ resource: "cronjobs", group: "batch" },
|
||||
{ resource: "customresourcedefinitions", group: "apiextensions.k8s.io" },
|
||||
{ resource: "daemonsets", group: "apps" },
|
||||
{ resource: "deployments", group: "apps" },
|
||||
{ resource: "endpoints" },
|
||||
{ resource: "events" },
|
||||
{ resource: "horizontalpodautoscalers" },
|
||||
{ resource: "ingresses", group: "networking.k8s.io" },
|
||||
{ resource: "jobs", group: "batch" },
|
||||
{ resource: "namespaces" },
|
||||
{ resource: "networkpolicies", group: "networking.k8s.io" },
|
||||
{ resource: "nodes" },
|
||||
{ resource: "persistentvolumes" },
|
||||
{ resource: "pods" },
|
||||
{ resource: "podsecuritypolicies" },
|
||||
{ resource: "resourcequotas" },
|
||||
{ resource: "secrets" },
|
||||
{ resource: "services" },
|
||||
{ resource: "statefulsets", group: "apps" },
|
||||
{ resource: "storageclasses", group: "storage.k8s.io" },
|
||||
];
|
||||
|
||||
export function isAllowedResource(resources: KubeResource | KubeResource[]) {
|
||||
if (!Array.isArray(resources)) {
|
||||
resources = [resources];
|
||||
}
|
||||
const { allowedResources = [] } = getHostedCluster() || {};
|
||||
for (const resource of resources) {
|
||||
if (!allowedResources.includes(resource)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Register custom protocols
|
||||
|
||||
import { protocol } from "electron"
|
||||
import path from "path";
|
||||
|
||||
export function registerFileProtocol(name: string, basePath: string) {
|
||||
protocol.registerFileProtocol(name, (request, callback) => {
|
||||
const filePath = request.url.replace(name + "://", "");
|
||||
const absPath = path.resolve(basePath, filePath);
|
||||
callback({ path: absPath });
|
||||
})
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// Setup static folder for common assets
|
||||
|
||||
import path from "path";
|
||||
import { protocol } from "electron"
|
||||
import logger from "../main/logger";
|
||||
import { staticDir, staticProto, outDir } from "./vars";
|
||||
|
||||
export function registerStaticProtocol(rootFolder = staticDir) {
|
||||
const scheme = staticProto.replace("://", "");
|
||||
protocol.registerFileProtocol(scheme, (request, callback) => {
|
||||
const relativePath = request.url.replace(staticProto, "");
|
||||
const absPath = path.resolve(rootFolder, relativePath);
|
||||
callback(absPath);
|
||||
}, (error) => {
|
||||
logger.debug(`Failed to register protocol "${scheme}"`, error);
|
||||
})
|
||||
}
|
||||
|
||||
export function getStaticUrl(filePath: string) {
|
||||
return staticProto + filePath;
|
||||
}
|
||||
|
||||
export function getStaticPath(filePath: string) {
|
||||
return path.resolve(staticDir, filePath);
|
||||
}
|
||||
+23
-7
@@ -1,12 +1,28 @@
|
||||
import request from "request"
|
||||
import requestPromise from "request-promise-native"
|
||||
import { userStore } from "./user-store"
|
||||
|
||||
export function globalRequestOpts(requestOpts: request.Options ) {
|
||||
const userPrefs = userStore.getPreferences()
|
||||
if (userPrefs.httpsProxy) {
|
||||
requestOpts.proxy = userPrefs.httpsProxy
|
||||
}
|
||||
requestOpts.rejectUnauthorized = !userPrefs.allowUntrustedCAs;
|
||||
// todo: get rid of "request" (deprecated)
|
||||
// https://github.com/lensapp/lens/issues/459
|
||||
|
||||
return requestOpts
|
||||
function getDefaultRequestOpts(): Partial<request.Options> {
|
||||
const { httpsProxy, allowUntrustedCAs } = userStore.preferences
|
||||
return {
|
||||
proxy: httpsProxy || undefined,
|
||||
rejectUnauthorized: !allowUntrustedCAs,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export function customRequest(opts: request.Options) {
|
||||
return request.defaults(getDefaultRequestOpts())(opts)
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export function customRequestPromise(opts: requestPromise.Options) {
|
||||
return requestPromise.defaults(getDefaultRequestOpts())(opts)
|
||||
}
|
||||
+25
-18
@@ -1,10 +1,13 @@
|
||||
import { app, App, remote } from "electron"
|
||||
import ua from "universal-analytics"
|
||||
import { machineIdSync } from "node-machine-id"
|
||||
import Singleton from "./utils/singleton";
|
||||
import { userStore } from "./user-store"
|
||||
import logger from "../main/logger";
|
||||
|
||||
const GA_ID = "UA-159377374-1"
|
||||
export class Tracker extends Singleton {
|
||||
static readonly GA_ID = "UA-159377374-1"
|
||||
|
||||
export class Tracker {
|
||||
protected visitor: ua.Visitor
|
||||
protected machineId: string = null;
|
||||
protected ip: string = null;
|
||||
@@ -12,31 +15,35 @@ export class Tracker {
|
||||
protected locale: string;
|
||||
protected electronUA: string;
|
||||
|
||||
constructor(app: Electron.App) {
|
||||
private constructor(app: App) {
|
||||
super();
|
||||
try {
|
||||
this.visitor = ua(GA_ID, machineIdSync(), {strictCidFormat: false})
|
||||
this.visitor = ua(Tracker.GA_ID, machineIdSync(), { strictCidFormat: false })
|
||||
} catch (error) {
|
||||
this.visitor = ua(GA_ID)
|
||||
this.visitor = ua(Tracker.GA_ID)
|
||||
}
|
||||
this.visitor.set("dl", "https://telemetry.k8slens.dev")
|
||||
}
|
||||
|
||||
public async event(eventCategory: string, eventAction: string) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
if (!this.telemetryAllowed()) {
|
||||
resolve()
|
||||
return
|
||||
protected async isTelemetryAllowed(): Promise<boolean> {
|
||||
return userStore.preferences.allowTelemetry;
|
||||
}
|
||||
|
||||
async event(eventCategory: string, eventAction: string, otherParams = {}) {
|
||||
try {
|
||||
const allowed = await this.isTelemetryAllowed();
|
||||
if (!allowed) {
|
||||
return;
|
||||
}
|
||||
this.visitor.event({
|
||||
ec: eventCategory,
|
||||
ea: eventAction
|
||||
ea: eventAction,
|
||||
...otherParams,
|
||||
}).send()
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
|
||||
protected telemetryAllowed() {
|
||||
const userPrefs = userStore.getPreferences()
|
||||
return !!userPrefs.allowTelemetry
|
||||
} catch (err) {
|
||||
logger.error(`Failed to track "${eventCategory}:${eventAction}"`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const tracker = Tracker.getInstance<Tracker>(app || remote.app);
|
||||
+80
-56
@@ -1,9 +1,16 @@
|
||||
import ElectronStore from "electron-store"
|
||||
import * as version210Beta4 from "../migrations/user-store/2.1.0-beta.4"
|
||||
import type { ThemeId } from "../renderer/theme.store";
|
||||
import semver from "semver"
|
||||
import { action, observable, reaction, toJS } from "mobx";
|
||||
import { BaseStore } from "./base-store";
|
||||
import migrations from "../migrations/user-store"
|
||||
import { getAppVersion } from "./utils/app-version";
|
||||
import { getKubeConfigLocal, loadConfig } from "./kube-helpers";
|
||||
import { tracker } from "./tracker";
|
||||
|
||||
export interface User {
|
||||
id?: string;
|
||||
export interface UserStoreModel {
|
||||
lastSeenAppVersion: string;
|
||||
seenContexts: string[];
|
||||
preferences: UserPreferences;
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
@@ -11,76 +18,93 @@ export interface UserPreferences {
|
||||
colorTheme?: string;
|
||||
allowUntrustedCAs?: boolean;
|
||||
allowTelemetry?: boolean;
|
||||
downloadMirror?: string;
|
||||
downloadMirror?: string | "default";
|
||||
}
|
||||
|
||||
export class UserStore {
|
||||
private static instance: UserStore;
|
||||
public store: ElectronStore;
|
||||
export class UserStore extends BaseStore<UserStoreModel> {
|
||||
static readonly defaultTheme: ThemeId = "kontena-dark"
|
||||
|
||||
private constructor() {
|
||||
this.store = new ElectronStore({
|
||||
// @ts-ignore
|
||||
// fixme: tests are failed without "projectVersion"
|
||||
projectVersion: getAppVersion(),
|
||||
migrations: {
|
||||
"2.1.0-beta.4": version210Beta4.migration,
|
||||
}
|
||||
super({
|
||||
// configName: "lens-user-store", // todo: migrate from default "config.json"
|
||||
migrations: migrations,
|
||||
});
|
||||
|
||||
// track telemetry availability
|
||||
reaction(() => this.preferences.allowTelemetry, allowed => {
|
||||
tracker.event("telemetry", allowed ? "enabled" : "disabled");
|
||||
});
|
||||
|
||||
// refresh new contexts
|
||||
this.whenLoaded.then(this.refreshNewContexts);
|
||||
reaction(() => this.seenContexts.size, this.refreshNewContexts);
|
||||
}
|
||||
|
||||
public lastSeenAppVersion() {
|
||||
return this.store.get('lastSeenAppVersion', "0.0.0")
|
||||
@observable lastSeenAppVersion = "0.0.0"
|
||||
@observable seenContexts = observable.set<string>();
|
||||
@observable newContexts = observable.set<string>();
|
||||
|
||||
@observable preferences: UserPreferences = {
|
||||
allowTelemetry: true,
|
||||
allowUntrustedCAs: false,
|
||||
colorTheme: UserStore.defaultTheme,
|
||||
downloadMirror: "default",
|
||||
};
|
||||
|
||||
get isNewVersion() {
|
||||
return semver.gt(getAppVersion(), this.lastSeenAppVersion);
|
||||
}
|
||||
|
||||
public setLastSeenAppVersion(version: string) {
|
||||
this.store.set('lastSeenAppVersion', version)
|
||||
@action
|
||||
resetTheme() {
|
||||
this.preferences.colorTheme = UserStore.defaultTheme;
|
||||
}
|
||||
|
||||
public getSeenContexts(): Array<string> {
|
||||
return this.store.get("seenContexts", [])
|
||||
@action
|
||||
saveLastSeenAppVersion() {
|
||||
tracker.event("app", "whats-new-seen")
|
||||
this.lastSeenAppVersion = getAppVersion();
|
||||
}
|
||||
|
||||
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"
|
||||
protected refreshNewContexts = async () => {
|
||||
const kubeConfig = await getKubeConfigLocal();
|
||||
if (kubeConfig) {
|
||||
this.newContexts.clear();
|
||||
const localContexts = loadConfig(kubeConfig).getContexts();
|
||||
localContexts
|
||||
.filter(ctx => ctx.cluster)
|
||||
.filter(ctx => !this.seenContexts.has(ctx.name))
|
||||
.forEach(ctx => this.newContexts.add(ctx.name));
|
||||
}
|
||||
if (!prefs.downloadMirror) {
|
||||
prefs.downloadMirror = "default"
|
||||
}
|
||||
if (prefs.allowTelemetry === undefined) {
|
||||
prefs.allowTelemetry = true
|
||||
}
|
||||
|
||||
return prefs
|
||||
}
|
||||
|
||||
static getInstance(): UserStore {
|
||||
if (!UserStore.instance) {
|
||||
UserStore.instance = new UserStore();
|
||||
}
|
||||
return UserStore.instance;
|
||||
@action
|
||||
markNewContextsAsSeen() {
|
||||
const { seenContexts, newContexts } = this;
|
||||
this.seenContexts.replace([...seenContexts, ...newContexts]);
|
||||
this.newContexts.clear();
|
||||
}
|
||||
|
||||
static resetInstance() {
|
||||
UserStore.instance = null
|
||||
@action
|
||||
protected fromStore(data: Partial<UserStoreModel> = {}) {
|
||||
const { lastSeenAppVersion, seenContexts = [], preferences } = data
|
||||
if (lastSeenAppVersion) {
|
||||
this.lastSeenAppVersion = lastSeenAppVersion;
|
||||
}
|
||||
this.seenContexts.replace(seenContexts);
|
||||
Object.assign(this.preferences, preferences);
|
||||
}
|
||||
|
||||
toJSON(): UserStoreModel {
|
||||
const model: UserStoreModel = {
|
||||
lastSeenAppVersion: this.lastSeenAppVersion,
|
||||
seenContexts: Array.from(this.seenContexts),
|
||||
preferences: this.preferences,
|
||||
}
|
||||
return toJS(model, {
|
||||
recurseEverything: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const userStore: UserStore = UserStore.getInstance();
|
||||
|
||||
export { userStore };
|
||||
export const userStore = UserStore.getInstance<UserStore>();
|
||||
@@ -1,72 +0,0 @@
|
||||
import mockFs from "mock-fs"
|
||||
import { userStore, UserStore } from "./user-store"
|
||||
|
||||
// Console.log needs to be called before fs-mocks, see https://github.com/tschaub/mock-fs/issues/234
|
||||
console.log("");
|
||||
|
||||
describe("for an empty config", () => {
|
||||
beforeEach(() => {
|
||||
UserStore.resetInstance()
|
||||
const mockOpts = {
|
||||
'tmp': {
|
||||
'config.json': JSON.stringify({})
|
||||
}
|
||||
}
|
||||
mockFs(mockOpts)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
it("allows setting and retrieving lastSeenAppVersion", async () => {
|
||||
userStore.setLastSeenAppVersion("1.2.3");
|
||||
expect(userStore.lastSeenAppVersion()).toBe("1.2.3");
|
||||
})
|
||||
|
||||
it("allows adding and listing seen contexts", async () => {
|
||||
userStore.storeSeenContext(['foo'])
|
||||
expect(userStore.getSeenContexts().length).toBe(1)
|
||||
userStore.storeSeenContext(['foo', 'bar'])
|
||||
const seenContexts = userStore.getSeenContexts()
|
||||
expect(seenContexts.length).toBe(2) // check 'foo' isn't added twice
|
||||
expect(seenContexts[0]).toBe('foo')
|
||||
expect(seenContexts[1]).toBe('bar')
|
||||
})
|
||||
|
||||
it("allows setting and getting preferences", async () => {
|
||||
userStore.setPreferences({
|
||||
httpsProxy: 'abcd://defg',
|
||||
})
|
||||
const storedPreferences = userStore.getPreferences()
|
||||
expect(storedPreferences.httpsProxy).toBe('abcd://defg')
|
||||
expect(storedPreferences.colorTheme).toBe('dark') // defaults to dark
|
||||
userStore.setPreferences({
|
||||
colorTheme: 'light'
|
||||
})
|
||||
expect(userStore.getPreferences().colorTheme).toBe('light')
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrations", () => {
|
||||
beforeEach(() => {
|
||||
UserStore.resetInstance()
|
||||
const mockOpts = {
|
||||
'tmp': {
|
||||
'config.json': JSON.stringify({
|
||||
user: { username: 'foobar' },
|
||||
preferences: { colorTheme: 'light' },
|
||||
})
|
||||
}
|
||||
}
|
||||
mockFs(mockOpts)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
it("sets last seen app version to 0.0.0", async () => {
|
||||
expect(userStore.lastSeenAppVersion()).toBe('0.0.0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import mockFs from "mock-fs"
|
||||
|
||||
jest.mock("electron", () => {
|
||||
return {
|
||||
app: {
|
||||
getVersion: () => '99.99.99',
|
||||
getPath: () => 'tmp',
|
||||
getLocale: () => 'en'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { UserStore } from "./user-store"
|
||||
import { SemVer } from "semver"
|
||||
import electron from "electron"
|
||||
|
||||
describe("user store tests", () => {
|
||||
describe("for an empty config", () => {
|
||||
beforeEach(() => {
|
||||
UserStore.resetInstance()
|
||||
mockFs({ tmp: { 'config.json': "{}" } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
it("allows setting and retrieving lastSeenAppVersion", () => {
|
||||
const us = UserStore.getInstance<UserStore>();
|
||||
|
||||
us.lastSeenAppVersion = "1.2.3";
|
||||
expect(us.lastSeenAppVersion).toBe("1.2.3");
|
||||
})
|
||||
|
||||
it("allows adding and listing seen contexts", () => {
|
||||
const us = UserStore.getInstance<UserStore>();
|
||||
|
||||
us.seenContexts.add('foo')
|
||||
expect(us.seenContexts.size).toBe(1)
|
||||
|
||||
us.seenContexts.add('foo')
|
||||
us.seenContexts.add('bar')
|
||||
expect(us.seenContexts.size).toBe(2) // check 'foo' isn't added twice
|
||||
expect(us.seenContexts.has('foo')).toBe(true)
|
||||
expect(us.seenContexts.has('bar')).toBe(true)
|
||||
})
|
||||
|
||||
it("allows setting and getting preferences", () => {
|
||||
const us = UserStore.getInstance<UserStore>();
|
||||
|
||||
us.preferences.httpsProxy = 'abcd://defg';
|
||||
|
||||
expect(us.preferences.httpsProxy).toBe('abcd://defg')
|
||||
expect(us.preferences.colorTheme).toBe(UserStore.defaultTheme)
|
||||
|
||||
us.preferences.colorTheme = "light";
|
||||
expect(us.preferences.colorTheme).toBe('light')
|
||||
})
|
||||
|
||||
it("correctly resets theme to default value", () => {
|
||||
const us = UserStore.getInstance<UserStore>();
|
||||
|
||||
us.preferences.colorTheme = "some other theme";
|
||||
us.resetTheme();
|
||||
expect(us.preferences.colorTheme).toBe(UserStore.defaultTheme);
|
||||
})
|
||||
|
||||
it("correctly calculates if the last seen version is an old release", () => {
|
||||
const us = UserStore.getInstance<UserStore>();
|
||||
|
||||
expect(us.isNewVersion).toBe(true);
|
||||
|
||||
us.lastSeenAppVersion = (new SemVer(electron.app.getVersion())).inc("major").format();
|
||||
expect(us.isNewVersion).toBe(false);
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrations", () => {
|
||||
beforeEach(() => {
|
||||
UserStore.resetInstance()
|
||||
mockFs({
|
||||
'tmp': {
|
||||
'config.json': JSON.stringify({
|
||||
user: { username: 'foobar' },
|
||||
preferences: { colorTheme: 'light' },
|
||||
lastSeenAppVersion: '1.2.3'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
it("sets last seen app version to 0.0.0", () => {
|
||||
const us = UserStore.getInstance<UserStore>();
|
||||
|
||||
expect(us.lastSeenAppVersion).toBe('0.0.0')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
// Clone json-serializable object
|
||||
|
||||
export function cloneJsonObject<T = object>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
// Setup variable in global scope (top-level object)
|
||||
// Global type definition must be added separately to `mocks.d.ts` in form:
|
||||
// declare const __globalName: any;
|
||||
|
||||
export function defineGlobal(propName: string, descriptor: PropertyDescriptor) {
|
||||
const scope = typeof global !== "undefined" ? global : window;
|
||||
if (scope.hasOwnProperty(propName)) {
|
||||
console.info(`Global variable "${propName}" already exists. Skipping.`)
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(scope, propName, descriptor);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Create random system name
|
||||
|
||||
export function getRandId({ prefix = "", suffix = "", sep = "_" } = {}) {
|
||||
const randId = () => Math.random().toString(16).substr(2);
|
||||
return [prefix, randId(), suffix].filter(s => s).join(sep);
|
||||
}
|
||||
@@ -3,3 +3,5 @@
|
||||
export * from "./base64"
|
||||
export * from "./camelCase"
|
||||
export * from "./splitArray"
|
||||
export * from "./getRandId"
|
||||
export * from "./cloneJson"
|
||||
@@ -1,16 +0,0 @@
|
||||
import { app, remote } from "electron"
|
||||
import { ensureDirSync, writeFileSync } from "fs-extra"
|
||||
import * as path from "path"
|
||||
|
||||
// Writes kubeconfigs to "embedded" store, i.e. .../Lens/kubeconfigs/
|
||||
export function writeEmbeddedKubeConfig(clusterId: string, kubeConfig: string): string {
|
||||
// This can be called from main & renderer
|
||||
const a = (app || remote.app)
|
||||
const kubeConfigBase = path.join(a.getPath("userData"), "kubeconfigs")
|
||||
ensureDirSync(kubeConfigBase)
|
||||
|
||||
const kubeConfigFile = path.join(kubeConfigBase, clusterId)
|
||||
writeFileSync(kubeConfigFile, kubeConfig)
|
||||
|
||||
return kubeConfigFile
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Narrowing class instances to the one.
|
||||
* Use "private" or "protected" modifier for constructor (when overriding) to disallow "new" usage.
|
||||
*
|
||||
* @example
|
||||
* const usersStore: UsersStore = UsersStore.getInstance();
|
||||
*/
|
||||
|
||||
type Constructor<T = {}> = new (...args: any[]) => T;
|
||||
|
||||
class Singleton {
|
||||
private static instances = new WeakMap<object, Singleton>();
|
||||
|
||||
// todo: improve types inferring
|
||||
static getInstance<T>(...args: ConstructorParameters<Constructor<T>>): T {
|
||||
if (!Singleton.instances.has(this)) {
|
||||
Singleton.instances.set(this, Reflect.construct(this, args));
|
||||
}
|
||||
return Singleton.instances.get(this) as T;
|
||||
}
|
||||
|
||||
static resetInstance() {
|
||||
Singleton.instances.delete(this);
|
||||
}
|
||||
}
|
||||
|
||||
export { Singleton }
|
||||
export default Singleton;
|
||||
+19
-17
@@ -1,37 +1,39 @@
|
||||
// App's common configuration for any process (main, renderer, build pipeline, etc.)
|
||||
import path from "path";
|
||||
import packageInfo from "../../package.json"
|
||||
import { defineGlobal } from "./utils/defineGlobal";
|
||||
|
||||
// Temp
|
||||
export const reactAppName = "app_react"
|
||||
export const vueAppName = "app_vue"
|
||||
|
||||
// Flags
|
||||
export const isMac = process.platform === "darwin"
|
||||
export const isWindows = process.platform === "win32"
|
||||
export const isDebugging = process.env.DEBUG === "true";
|
||||
export const isProduction = process.env.NODE_ENV === "production"
|
||||
export const isDevelopment = isDebugging || !isProduction;
|
||||
export const buildVersion = process.env.BUILD_VERSION;
|
||||
export const isTestEnv = !!process.env.JEST_WORKER_ID;
|
||||
|
||||
// Paths
|
||||
export const appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}`
|
||||
export const publicPath = "/build/"
|
||||
|
||||
// Webpack build paths
|
||||
export const contextDir = process.cwd();
|
||||
export const staticDir = path.join(contextDir, "static");
|
||||
export const outDir = path.join(contextDir, "out");
|
||||
export const buildDir = path.join(contextDir, "static", publicPath);
|
||||
export const mainDir = path.join(contextDir, "src/main");
|
||||
export const rendererDir = path.join(contextDir, "src/renderer");
|
||||
export const htmlTemplate = path.resolve(rendererDir, "template.html");
|
||||
export const sassCommonVars = path.resolve(rendererDir, "components/vars.scss");
|
||||
|
||||
// Apis
|
||||
export const staticProto = "static://"
|
||||
// Special runtime paths
|
||||
defineGlobal("__static", {
|
||||
get() {
|
||||
if (isDevelopment) {
|
||||
return path.resolve(contextDir, "static");
|
||||
}
|
||||
return path.resolve(process.resourcesPath, "static")
|
||||
}
|
||||
})
|
||||
|
||||
export const apiPrefix = {
|
||||
BASE: '/api',
|
||||
KUBE_BASE: '/api-kube', // kubernetes cluster api
|
||||
KUBE_HELM: '/api-helm', // helm charts api
|
||||
KUBE_RESOURCE_APPLIER: "/api-resource",
|
||||
};
|
||||
// Apis
|
||||
export const apiPrefix = "/api" // local router apis
|
||||
export const apiKubePrefix = "/api-kube" // k8s cluster apis
|
||||
|
||||
// Links
|
||||
export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues"
|
||||
|
||||
@@ -1,78 +1,109 @@
|
||||
import ElectronStore from "electron-store"
|
||||
import { action, computed, observable, toJS } from "mobx";
|
||||
import { BaseStore } from "./base-store";
|
||||
import { clusterStore } from "./cluster-store"
|
||||
|
||||
export interface WorkspaceData {
|
||||
id: string;
|
||||
export type WorkspaceId = string;
|
||||
|
||||
export interface WorkspaceStoreModel {
|
||||
currentWorkspace?: WorkspaceId;
|
||||
workspaces: Workspace[]
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
id: WorkspaceId;
|
||||
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 {
|
||||
public static defaultId = "default"
|
||||
private static instance: WorkspaceStore;
|
||||
public store: ElectronStore;
|
||||
export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
|
||||
static readonly defaultId: WorkspaceId = "default"
|
||||
|
||||
private constructor() {
|
||||
this.store = new ElectronStore({
|
||||
name: "lens-workspace-store"
|
||||
super({
|
||||
configName: "lens-workspace-store",
|
||||
});
|
||||
}
|
||||
|
||||
@observable currentWorkspaceId = WorkspaceStore.defaultId;
|
||||
|
||||
@observable workspaces = observable.map<WorkspaceId, Workspace>({
|
||||
[WorkspaceStore.defaultId]: {
|
||||
id: WorkspaceStore.defaultId,
|
||||
name: "default"
|
||||
}
|
||||
});
|
||||
|
||||
@computed get currentWorkspace(): Workspace {
|
||||
return this.getById(this.currentWorkspaceId);
|
||||
}
|
||||
|
||||
@computed get workspacesList() {
|
||||
return Array.from(this.workspaces.values());
|
||||
}
|
||||
|
||||
isDefault(id: WorkspaceId) {
|
||||
return id === WorkspaceStore.defaultId;
|
||||
}
|
||||
|
||||
getById(id: WorkspaceId): Workspace {
|
||||
return this.workspaces.get(id);
|
||||
}
|
||||
|
||||
@action
|
||||
setActive(id = WorkspaceStore.defaultId) {
|
||||
if (!this.getById(id)) {
|
||||
throw new Error(`workspace ${id} doesn't exist`);
|
||||
}
|
||||
|
||||
this.currentWorkspaceId = id;
|
||||
}
|
||||
|
||||
@action
|
||||
saveWorkspace(workspace: Workspace) {
|
||||
const id = workspace.id;
|
||||
const existingWorkspace = this.getById(id);
|
||||
if (existingWorkspace) {
|
||||
Object.assign(existingWorkspace, workspace);
|
||||
} else {
|
||||
this.workspaces.set(id, workspace);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
removeWorkspace(id: WorkspaceId) {
|
||||
const workspace = this.getById(id);
|
||||
if (!workspace) return;
|
||||
if (this.isDefault(id)) {
|
||||
throw new Error("Cannot remove default workspace");
|
||||
}
|
||||
if (this.currentWorkspaceId === id) {
|
||||
this.currentWorkspaceId = WorkspaceStore.defaultId; // reset to default
|
||||
}
|
||||
this.workspaces.delete(id);
|
||||
clusterStore.removeByWorkspaceId(id)
|
||||
}
|
||||
|
||||
@action
|
||||
protected fromStore({ currentWorkspace, workspaces = [] }: WorkspaceStoreModel) {
|
||||
if (currentWorkspace) {
|
||||
this.currentWorkspaceId = currentWorkspace
|
||||
}
|
||||
if (workspaces.length) {
|
||||
this.workspaces.clear();
|
||||
workspaces.forEach(workspace => {
|
||||
this.workspaces.set(workspace.id, workspace)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
toJSON(): WorkspaceStoreModel {
|
||||
return toJS({
|
||||
currentWorkspace: this.currentWorkspaceId,
|
||||
workspaces: this.workspacesList,
|
||||
}, {
|
||||
recurseEverything: true
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
if (workspace.id === WorkspaceStore.defaultId) {
|
||||
throw new Error("Cannot remove default 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().find( ws => ws.id === WorkspaceStore.defaultId)) {
|
||||
workspaceStore.storeWorkspace({
|
||||
id: WorkspaceStore.defaultId,
|
||||
name: "default"
|
||||
})
|
||||
}
|
||||
|
||||
export { workspaceStore }
|
||||
export const workspaceStore = WorkspaceStore.getInstance<WorkspaceStore>()
|
||||
@@ -0,0 +1,128 @@
|
||||
import mockFs from "mock-fs"
|
||||
|
||||
jest.mock("electron", () => {
|
||||
return {
|
||||
app: {
|
||||
getVersion: () => '99.99.99',
|
||||
getPath: () => 'tmp',
|
||||
getLocale: () => 'en'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { WorkspaceStore } from "./workspace-store"
|
||||
|
||||
describe("workspace store tests", () => {
|
||||
describe("for an empty config", () => {
|
||||
beforeEach(async () => {
|
||||
WorkspaceStore.resetInstance()
|
||||
mockFs({ tmp: { 'lens-workspace-store.json': "{}" } })
|
||||
|
||||
await WorkspaceStore.getInstance<WorkspaceStore>().load();
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
it("default workspace should always exist", () => {
|
||||
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
|
||||
|
||||
expect(ws.workspaces.size).toBe(1);
|
||||
expect(ws.getById(WorkspaceStore.defaultId)).not.toBe(null);
|
||||
})
|
||||
|
||||
it("cannot remove the default workspace", () => {
|
||||
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
|
||||
|
||||
expect(() => ws.removeWorkspace(WorkspaceStore.defaultId)).toThrowError("Cannot remove");
|
||||
})
|
||||
|
||||
it("can update default workspace name", () => {
|
||||
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
|
||||
|
||||
ws.saveWorkspace({
|
||||
id: WorkspaceStore.defaultId,
|
||||
name: "foobar",
|
||||
});
|
||||
|
||||
expect(ws.currentWorkspace.name).toBe("foobar");
|
||||
})
|
||||
|
||||
it("can add workspaces", () => {
|
||||
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
|
||||
|
||||
ws.saveWorkspace({
|
||||
id: "123",
|
||||
name: "foobar",
|
||||
});
|
||||
|
||||
expect(ws.getById("123").name).toBe("foobar");
|
||||
})
|
||||
|
||||
it("cannot set a non-existent workspace to be active", () => {
|
||||
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
|
||||
|
||||
expect(() => ws.setActive("abc")).toThrow("doesn't exist");
|
||||
})
|
||||
|
||||
it("can set a existent workspace to be active", () => {
|
||||
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
|
||||
|
||||
ws.saveWorkspace({
|
||||
id: "abc",
|
||||
name: "foobar",
|
||||
});
|
||||
|
||||
expect(() => ws.setActive("abc")).not.toThrowError();
|
||||
})
|
||||
|
||||
it("can remove a workspace", () => {
|
||||
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
|
||||
|
||||
ws.saveWorkspace({
|
||||
id: "123",
|
||||
name: "foobar",
|
||||
});
|
||||
ws.saveWorkspace({
|
||||
id: "1234",
|
||||
name: "foobar 1",
|
||||
});
|
||||
ws.removeWorkspace("123");
|
||||
|
||||
expect(ws.workspaces.size).toBe(2);
|
||||
})
|
||||
})
|
||||
|
||||
describe("for a non-empty config", () => {
|
||||
beforeEach(async () => {
|
||||
WorkspaceStore.resetInstance()
|
||||
mockFs({
|
||||
tmp: {
|
||||
'lens-workspace-store.json': JSON.stringify({
|
||||
currentWorkspace: "abc",
|
||||
workspaces: [{
|
||||
id: "abc",
|
||||
name: "test"
|
||||
}, {
|
||||
id: "default",
|
||||
name: "default"
|
||||
}]
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
await WorkspaceStore.getInstance<WorkspaceStore>().load();
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
it("doesn't revert to default workspace", async () => {
|
||||
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
|
||||
|
||||
expect(ws.currentWorkspaceId).toBe("abc");
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user