mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Merge branch 'master' into mobx-6.2
# Conflicts: # src/common/hotbar-store.ts # src/main/catalog-pusher.ts # src/main/cluster-manager.ts # src/main/cluster.ts # src/main/index.ts # src/main/window-manager.ts # src/renderer/components/+add-cluster/add-cluster.tsx
This commit is contained in:
commit
81607fd57a
@ -173,8 +173,3 @@ jobs:
|
|||||||
AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID)
|
AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID)
|
||||||
AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY)
|
AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY)
|
||||||
BUILD_NUMBER: $(Build.BuildNumber)
|
BUILD_NUMBER: $(Build.BuildNumber)
|
||||||
- script: make publish-npm
|
|
||||||
displayName: Publish npm package
|
|
||||||
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"
|
|
||||||
env:
|
|
||||||
NPM_TOKEN: $(NPM_TOKEN)
|
|
||||||
|
|||||||
33
.github/workflows/publish-npm.yml
vendored
Normal file
33
.github/workflows/publish-npm.yml
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
name: Publish NPM
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types:
|
||||||
|
- published
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Publish NPM
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [12.x]
|
||||||
|
steps:
|
||||||
|
- name: Checkout Release
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Using Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
|
||||||
|
- name: Generate NPM package
|
||||||
|
run: |
|
||||||
|
make build-npm
|
||||||
|
|
||||||
|
- name: publish new release
|
||||||
|
if: contains(github.ref, 'refs/tags/v')
|
||||||
|
run: |
|
||||||
|
make publish-npm
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
2
Makefile
2
Makefile
@ -106,7 +106,7 @@ build-npm: build-extension-types copy-extension-themes src/extensions/npm/extens
|
|||||||
yarn npm:fix-package-version
|
yarn npm:fix-package-version
|
||||||
|
|
||||||
.PHONY: build-extension-types
|
.PHONY: build-extension-types
|
||||||
build-extension-types: src/extensions/npm/extensions/dist
|
build-extension-types: node_modules src/extensions/npm/extensions/dist
|
||||||
|
|
||||||
.PHONY: publish-npm
|
.PHONY: publish-npm
|
||||||
publish-npm: node_modules build-npm
|
publish-npm: node_modules build-npm
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import { Application } from "spectron";
|
import { Application } from "spectron";
|
||||||
import * as utils from "../helpers/utils";
|
import * as utils from "../helpers/utils";
|
||||||
import { addMinikubeCluster, minikubeReady, waitForMinikubeDashboard } from "../helpers/minikube";
|
import { minikubeReady, waitForMinikubeDashboard } from "../helpers/minikube";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import * as util from "util";
|
import * as util from "util";
|
||||||
|
|
||||||
@ -25,7 +25,6 @@ describe("Lens cluster pages", () => {
|
|||||||
let clusterAdded = false;
|
let clusterAdded = false;
|
||||||
const addCluster = async () => {
|
const addCluster = async () => {
|
||||||
await app.client.waitUntilTextExists("div", "Catalog");
|
await app.client.waitUntilTextExists("div", "Catalog");
|
||||||
await addMinikubeCluster(app);
|
|
||||||
await waitForMinikubeDashboard(app);
|
await waitForMinikubeDashboard(app);
|
||||||
await app.client.click('a[href="/nodes"]');
|
await app.client.click('a[href="/nodes"]');
|
||||||
await app.client.waitUntilTextExists("div.TableCell", "Ready");
|
await app.client.waitUntilTextExists("div.TableCell", "Ready");
|
||||||
|
|||||||
@ -38,28 +38,12 @@ export function minikubeReady(testNamespace: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addMinikubeCluster(app: Application) {
|
export async function waitForMinikubeDashboard(app: Application) {
|
||||||
await app.client.waitForVisible("button.MuiSpeedDial-fab");
|
|
||||||
await app.client.moveToObject("button.MuiSpeedDial-fab");
|
|
||||||
await app.client.waitForVisible(`button[title="Add from kubeconfig"]`);
|
|
||||||
await app.client.click(`button[title="Add from kubeconfig"]`);
|
|
||||||
await app.client.waitUntilTextExists("div", "Select kubeconfig file");
|
|
||||||
await app.client.click("div.Select__control"); // show the context drop-down list
|
|
||||||
await app.client.waitUntilTextExists("div", "minikube");
|
|
||||||
|
|
||||||
if (!await app.client.$("button.primary").isEnabled()) {
|
|
||||||
await app.client.click("div.minikube"); // select minikube context
|
|
||||||
} // else the only context, which must be 'minikube', is automatically selected
|
|
||||||
await app.client.click("div.Select__control"); // hide the context drop-down list (it might be obscuring the Add cluster(s) button)
|
|
||||||
await app.client.click("button.primary"); // add minikube cluster
|
|
||||||
await app.client.waitUntilTextExists("div.TableCell", "minikube");
|
await app.client.waitUntilTextExists("div.TableCell", "minikube");
|
||||||
await app.client.waitForExist(".Input.SearchInput input");
|
await app.client.waitForExist(".Input.SearchInput input");
|
||||||
await app.client.setValue(".Input.SearchInput input", "minikube");
|
await app.client.setValue(".Input.SearchInput input", "minikube");
|
||||||
await app.client.waitUntilTextExists("div.TableCell", "minikube");
|
await app.client.waitUntilTextExists("div.TableCell", "minikube");
|
||||||
await app.client.click("div.TableRow");
|
await app.client.click("div.TableRow");
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitForMinikubeDashboard(app: Application) {
|
|
||||||
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
|
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
|
||||||
await app.client.waitForExist(`iframe[name="minikube"]`);
|
await app.client.waitForExist(`iframe[name="minikube"]`);
|
||||||
await app.client.frame("minikube");
|
await app.client.frame("minikube");
|
||||||
|
|||||||
@ -103,6 +103,11 @@
|
|||||||
"!**/node_modules"
|
"!**/node_modules"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "templates/",
|
||||||
|
"to": "./templates/",
|
||||||
|
"filter": "**/*.yaml"
|
||||||
|
},
|
||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"linux": {
|
"linux": {
|
||||||
|
|||||||
@ -92,7 +92,6 @@ describe("empty config", () => {
|
|||||||
expect(storedCluster.id).toBe("foo");
|
expect(storedCluster.id).toBe("foo");
|
||||||
expect(storedCluster.preferences.terminalCWD).toBe("/tmp");
|
expect(storedCluster.preferences.terminalCWD).toBe("/tmp");
|
||||||
expect(storedCluster.preferences.icon).toBe("data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5");
|
expect(storedCluster.preferences.icon).toBe("data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5");
|
||||||
expect(storedCluster.enabled).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes cluster from store", async () => {
|
it("removes cluster from store", async () => {
|
||||||
@ -215,13 +214,6 @@ describe("config with existing clusters", () => {
|
|||||||
expect(storedClusters[1].preferences.terminalCWD).toBe("/foo2");
|
expect(storedClusters[1].preferences.terminalCWD).toBe("/foo2");
|
||||||
expect(storedClusters[2].id).toBe("cluster3");
|
expect(storedClusters[2].id).toBe("cluster3");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks owned cluster disabled by default", () => {
|
|
||||||
const storedClusters = ClusterStore.getInstance().clustersList;
|
|
||||||
|
|
||||||
expect(storedClusters[0].enabled).toBe(true);
|
|
||||||
expect(storedClusters[2].enabled).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("config with invalid cluster kubeconfig", () => {
|
describe("config with invalid cluster kubeconfig", () => {
|
||||||
@ -288,18 +280,35 @@ users:
|
|||||||
it("does not enable clusters with invalid kubeconfig", () => {
|
it("does not enable clusters with invalid kubeconfig", () => {
|
||||||
const storedClusters = ClusterStore.getInstance().clustersList;
|
const storedClusters = ClusterStore.getInstance().clustersList;
|
||||||
|
|
||||||
expect(storedClusters.length).toBe(2);
|
expect(storedClusters.length).toBe(1);
|
||||||
expect(storedClusters[0].enabled).toBeFalsy;
|
|
||||||
expect(storedClusters[1].id).toBe("cluster2");
|
|
||||||
expect(storedClusters[1].enabled).toBeTruthy;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const minimalValidKubeConfig = JSON.stringify({
|
const minimalValidKubeConfig = JSON.stringify({
|
||||||
apiVersion: "v1",
|
apiVersion: "v1",
|
||||||
clusters: [],
|
clusters: [{
|
||||||
users: [],
|
name: "minikube",
|
||||||
contexts: [],
|
cluster: {
|
||||||
|
server: "https://192.168.64.3:8443",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
"current-context": "minikube",
|
||||||
|
contexts: [{
|
||||||
|
context: {
|
||||||
|
cluster: "minikube",
|
||||||
|
user: "minikube",
|
||||||
|
},
|
||||||
|
name: "minikube",
|
||||||
|
}],
|
||||||
|
users: [{
|
||||||
|
name: "minikube",
|
||||||
|
user: {
|
||||||
|
"client-certificate": "/Users/foo/.minikube/client.crt",
|
||||||
|
"client-key": "/Users/foo/.minikube/client.key",
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
kind: "Config",
|
||||||
|
preferences: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("pre 2.0 config with an existing cluster", () => {
|
describe("pre 2.0 config with an existing cluster", () => {
|
||||||
@ -330,7 +339,7 @@ describe("pre 2.0 config with an existing cluster", () => {
|
|||||||
it("migrates to modern format with kubeconfig in a file", async () => {
|
it("migrates to modern format with kubeconfig in a file", async () => {
|
||||||
const config = ClusterStore.getInstance().clustersList[0].kubeConfigPath;
|
const config = ClusterStore.getInstance().clustersList[0].kubeConfigPath;
|
||||||
|
|
||||||
expect(fs.readFileSync(config, "utf8")).toContain(`"contexts":[]`);
|
expect(fs.readFileSync(config, "utf8")).toContain(`"contexts":[`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -402,8 +411,6 @@ describe("pre 2.6.0 config with a cluster that has arrays in auth config", () =>
|
|||||||
const config = fs.readFileSync(file, "utf8");
|
const config = fs.readFileSync(file, "utf8");
|
||||||
const kc = yaml.safeLoad(config);
|
const kc = yaml.safeLoad(config);
|
||||||
|
|
||||||
console.log(kc);
|
|
||||||
|
|
||||||
expect(kc.users[0].user["auth-provider"].config["access-token"]).toBe("should be string");
|
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(kc.users[0].user["auth-provider"].config["expiry"]).toBe("should be string");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,71 @@
|
|||||||
import mockFs from "mock-fs";
|
import mockFs from "mock-fs";
|
||||||
|
import { CatalogEntityItem } from "../../renderer/components/+catalog/catalog-entity.store";
|
||||||
import { ClusterStore } from "../cluster-store";
|
import { ClusterStore } from "../cluster-store";
|
||||||
import { HotbarStore } from "../hotbar-store";
|
import { HotbarStore } from "../hotbar-store";
|
||||||
|
|
||||||
|
const testCluster = {
|
||||||
|
uid: "test",
|
||||||
|
name: "test",
|
||||||
|
apiVersion: "v1",
|
||||||
|
kind: "Cluster",
|
||||||
|
status: {
|
||||||
|
phase: "Running"
|
||||||
|
},
|
||||||
|
spec: {},
|
||||||
|
getName: jest.fn(),
|
||||||
|
getId: jest.fn(),
|
||||||
|
onDetailsOpen: jest.fn(),
|
||||||
|
onContextMenuOpen: jest.fn(),
|
||||||
|
onSettingsOpen: jest.fn(),
|
||||||
|
metadata: {
|
||||||
|
uid: "test",
|
||||||
|
name: "test",
|
||||||
|
labels: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const minikubeCluster = {
|
||||||
|
uid: "minikube",
|
||||||
|
name: "minikube",
|
||||||
|
apiVersion: "v1",
|
||||||
|
kind: "Cluster",
|
||||||
|
status: {
|
||||||
|
phase: "Running"
|
||||||
|
},
|
||||||
|
spec: {},
|
||||||
|
getName: jest.fn(),
|
||||||
|
getId: jest.fn(),
|
||||||
|
onDetailsOpen: jest.fn(),
|
||||||
|
onContextMenuOpen: jest.fn(),
|
||||||
|
onSettingsOpen: jest.fn(),
|
||||||
|
metadata: {
|
||||||
|
uid: "minikube",
|
||||||
|
name: "minikube",
|
||||||
|
labels: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const awsCluster = {
|
||||||
|
uid: "aws",
|
||||||
|
name: "aws",
|
||||||
|
apiVersion: "v1",
|
||||||
|
kind: "Cluster",
|
||||||
|
status: {
|
||||||
|
phase: "Running"
|
||||||
|
},
|
||||||
|
spec: {},
|
||||||
|
getName: jest.fn(),
|
||||||
|
getId: jest.fn(),
|
||||||
|
onDetailsOpen: jest.fn(),
|
||||||
|
onContextMenuOpen: jest.fn(),
|
||||||
|
onSettingsOpen: jest.fn(),
|
||||||
|
metadata: {
|
||||||
|
uid: "aws",
|
||||||
|
name: "aws",
|
||||||
|
labels: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
describe("HotbarStore", () => {
|
describe("HotbarStore", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
ClusterStore.resetInstance();
|
ClusterStore.resetInstance();
|
||||||
@ -31,4 +95,137 @@ describe("HotbarStore", () => {
|
|||||||
expect(hotbarStore.hotbars.length).toEqual(2);
|
expect(hotbarStore.hotbars.length).toEqual(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("hotbar items", () => {
|
||||||
|
it("initially creates 12 empty cells", () => {
|
||||||
|
const hotbarStore = HotbarStore.createInstance();
|
||||||
|
|
||||||
|
hotbarStore.load();
|
||||||
|
expect(hotbarStore.getActive().items.length).toEqual(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds items", () => {
|
||||||
|
const hotbarStore = HotbarStore.createInstance();
|
||||||
|
const entity = new CatalogEntityItem(testCluster);
|
||||||
|
|
||||||
|
hotbarStore.load();
|
||||||
|
hotbarStore.addToHotbar(entity);
|
||||||
|
const items = hotbarStore.getActive().items.filter(Boolean);
|
||||||
|
|
||||||
|
expect(items.length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes items", () => {
|
||||||
|
const hotbarStore = HotbarStore.createInstance();
|
||||||
|
const entity = new CatalogEntityItem(testCluster);
|
||||||
|
|
||||||
|
hotbarStore.load();
|
||||||
|
hotbarStore.addToHotbar(entity);
|
||||||
|
hotbarStore.removeFromHotbar("test");
|
||||||
|
const items = hotbarStore.getActive().items.filter(Boolean);
|
||||||
|
|
||||||
|
expect(items.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing if removing with invalid uid", () => {
|
||||||
|
const hotbarStore = HotbarStore.createInstance();
|
||||||
|
const entity = new CatalogEntityItem(testCluster);
|
||||||
|
|
||||||
|
hotbarStore.load();
|
||||||
|
hotbarStore.addToHotbar(entity);
|
||||||
|
hotbarStore.removeFromHotbar("invalid uid");
|
||||||
|
const items = hotbarStore.getActive().items.filter(Boolean);
|
||||||
|
|
||||||
|
expect(items.length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves item to empty cell", () => {
|
||||||
|
const hotbarStore = HotbarStore.createInstance();
|
||||||
|
const test = new CatalogEntityItem(testCluster);
|
||||||
|
const minikube = new CatalogEntityItem(minikubeCluster);
|
||||||
|
const aws = new CatalogEntityItem(awsCluster);
|
||||||
|
|
||||||
|
hotbarStore.load();
|
||||||
|
hotbarStore.addToHotbar(test);
|
||||||
|
hotbarStore.addToHotbar(minikube);
|
||||||
|
hotbarStore.addToHotbar(aws);
|
||||||
|
|
||||||
|
expect(hotbarStore.getActive().items[5]).toBeNull();
|
||||||
|
|
||||||
|
hotbarStore.restackItems(1, 5);
|
||||||
|
|
||||||
|
expect(hotbarStore.getActive().items[5]).toBeTruthy();
|
||||||
|
expect(hotbarStore.getActive().items[5].entity.uid).toEqual("minikube");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves items down", () => {
|
||||||
|
const hotbarStore = HotbarStore.createInstance();
|
||||||
|
const test = new CatalogEntityItem(testCluster);
|
||||||
|
const minikube = new CatalogEntityItem(minikubeCluster);
|
||||||
|
const aws = new CatalogEntityItem(awsCluster);
|
||||||
|
|
||||||
|
hotbarStore.load();
|
||||||
|
hotbarStore.addToHotbar(test);
|
||||||
|
hotbarStore.addToHotbar(minikube);
|
||||||
|
hotbarStore.addToHotbar(aws);
|
||||||
|
|
||||||
|
// aws -> test
|
||||||
|
hotbarStore.restackItems(2, 0);
|
||||||
|
|
||||||
|
const items = hotbarStore.getActive().items.map(item => item?.entity.uid || null);
|
||||||
|
|
||||||
|
expect(items.slice(0, 4)).toEqual(["aws", "test", "minikube", null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves items up", () => {
|
||||||
|
const hotbarStore = HotbarStore.createInstance();
|
||||||
|
const test = new CatalogEntityItem(testCluster);
|
||||||
|
const minikube = new CatalogEntityItem(minikubeCluster);
|
||||||
|
const aws = new CatalogEntityItem(awsCluster);
|
||||||
|
|
||||||
|
hotbarStore.load();
|
||||||
|
hotbarStore.addToHotbar(test);
|
||||||
|
hotbarStore.addToHotbar(minikube);
|
||||||
|
hotbarStore.addToHotbar(aws);
|
||||||
|
|
||||||
|
// test -> aws
|
||||||
|
hotbarStore.restackItems(0, 2);
|
||||||
|
|
||||||
|
const items = hotbarStore.getActive().items.map(item => item?.entity.uid || null);
|
||||||
|
|
||||||
|
expect(items.slice(0, 4)).toEqual(["minikube", "aws", "test", null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when item moved to same cell", () => {
|
||||||
|
const hotbarStore = HotbarStore.createInstance();
|
||||||
|
const test = new CatalogEntityItem(testCluster);
|
||||||
|
|
||||||
|
hotbarStore.load();
|
||||||
|
hotbarStore.addToHotbar(test);
|
||||||
|
hotbarStore.restackItems(0, 0);
|
||||||
|
|
||||||
|
expect(hotbarStore.getActive().items[0].entity.uid).toEqual("test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws if invalid arguments provided", () => {
|
||||||
|
// Prevent writing to stderr during this render.
|
||||||
|
const err = console.error;
|
||||||
|
|
||||||
|
console.error = jest.fn();
|
||||||
|
|
||||||
|
const hotbarStore = HotbarStore.createInstance();
|
||||||
|
const test = new CatalogEntityItem(testCluster);
|
||||||
|
|
||||||
|
hotbarStore.load();
|
||||||
|
hotbarStore.addToHotbar(test);
|
||||||
|
|
||||||
|
expect(() => hotbarStore.restackItems(-5, 0)).toThrow();
|
||||||
|
expect(() => hotbarStore.restackItems(2, -1)).toThrow();
|
||||||
|
expect(() => hotbarStore.restackItems(14, 1)).toThrow();
|
||||||
|
expect(() => hotbarStore.restackItems(11, 112)).toThrow();
|
||||||
|
|
||||||
|
// Restore writing to stderr.
|
||||||
|
console.error = err;
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -39,7 +39,10 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
|
|||||||
onlyVisibleForSource: "local",
|
onlyVisibleForSource: "local",
|
||||||
onClick: async () => context.navigate(`/entity/${this.metadata.uid}/settings`)
|
onClick: async () => context.navigate(`/entity/${this.metadata.uid}/settings`)
|
||||||
},
|
},
|
||||||
{
|
];
|
||||||
|
|
||||||
|
if (this.metadata.labels["file"]?.startsWith(ClusterStore.storedKubeConfigFolder)) {
|
||||||
|
context.menuItems.push({
|
||||||
icon: "delete",
|
icon: "delete",
|
||||||
title: "Delete",
|
title: "Delete",
|
||||||
onlyVisibleForSource: "local",
|
onlyVisibleForSource: "local",
|
||||||
@ -47,8 +50,8 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
|
|||||||
confirm: {
|
confirm: {
|
||||||
message: `Remove Kubernetes Cluster "${this.metadata.name} from ${productName}?`
|
message: `Remove Kubernetes Cluster "${this.metadata.name} from ${productName}?`
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
];
|
}
|
||||||
|
|
||||||
if (this.status.phase == "connected") {
|
if (this.status.phase == "connected") {
|
||||||
context.menuItems.unshift({
|
context.menuItems.unshift({
|
||||||
|
|||||||
@ -64,11 +64,6 @@ export interface ClusterModel {
|
|||||||
/** Metadata */
|
/** Metadata */
|
||||||
metadata?: ClusterMetadata;
|
metadata?: ClusterMetadata;
|
||||||
|
|
||||||
/**
|
|
||||||
* If extension sets ownerRef it has to explicitly mark a cluster as enabled during onActive (or when cluster is saved)
|
|
||||||
*/
|
|
||||||
ownerRef?: string;
|
|
||||||
|
|
||||||
/** List of accessible namespaces */
|
/** List of accessible namespaces */
|
||||||
accessibleNamespaces?: string[];
|
accessibleNamespaces?: string[];
|
||||||
|
|
||||||
@ -174,9 +169,6 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
|||||||
protected pushStateToViewsAutomatically() {
|
protected pushStateToViewsAutomatically() {
|
||||||
if (ipcMain) {
|
if (ipcMain) {
|
||||||
this.disposer.push(
|
this.disposer.push(
|
||||||
reaction(() => this.enabledClustersList, () => {
|
|
||||||
this.pushState();
|
|
||||||
}),
|
|
||||||
reaction(() => this.connectedClustersList, () => {
|
reaction(() => this.connectedClustersList, () => {
|
||||||
this.pushState();
|
this.pushState();
|
||||||
}),
|
}),
|
||||||
@ -212,10 +204,6 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
|||||||
return Array.from(this.clusters.values());
|
return Array.from(this.clusters.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed get enabledClustersList(): Cluster[] {
|
|
||||||
return this.clustersList.filter((c) => c.enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
@computed get active(): Cluster | null {
|
@computed get active(): Cluster | null {
|
||||||
return this.getById(this.activeCluster);
|
return this.getById(this.activeCluster);
|
||||||
}
|
}
|
||||||
@ -234,13 +222,9 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
setActive(clusterId: ClusterId) {
|
setActive(clusterId: ClusterId) {
|
||||||
const cluster = this.clusters.get(clusterId);
|
this.activeCluster = this.clusters.has(clusterId)
|
||||||
|
? clusterId
|
||||||
if (!cluster?.enabled) {
|
: null;
|
||||||
clusterId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.activeCluster = clusterId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deactivate(id: ClusterId) {
|
deactivate(id: ClusterId) {
|
||||||
@ -276,10 +260,6 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
|||||||
? clusterOrModel
|
? clusterOrModel
|
||||||
: new Cluster(clusterOrModel);
|
: new Cluster(clusterOrModel);
|
||||||
|
|
||||||
if (!cluster.isManaged) {
|
|
||||||
cluster.enabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.clusters.set(cluster.id, cluster);
|
this.clusters.set(cluster.id, cluster);
|
||||||
|
|
||||||
return cluster;
|
return cluster;
|
||||||
@ -316,18 +296,18 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
|||||||
|
|
||||||
// update new clusters
|
// update new clusters
|
||||||
for (const clusterModel of clusters) {
|
for (const clusterModel of clusters) {
|
||||||
|
try {
|
||||||
let cluster = currentClusters.get(clusterModel.id);
|
let cluster = currentClusters.get(clusterModel.id);
|
||||||
|
|
||||||
if (cluster) {
|
if (cluster) {
|
||||||
cluster.updateModel(clusterModel);
|
cluster.updateModel(clusterModel);
|
||||||
} else {
|
} else {
|
||||||
cluster = new Cluster(clusterModel);
|
cluster = new Cluster(clusterModel);
|
||||||
|
|
||||||
if (!cluster.isManaged && cluster.apiUrl) {
|
|
||||||
cluster.enabled = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
newClusters.set(clusterModel.id, cluster);
|
newClusters.set(clusterModel.id, cluster);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// update removed clusters
|
// update removed clusters
|
||||||
@ -337,7 +317,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.activeCluster = newClusters.get(activeCluster)?.enabled ? activeCluster : null;
|
this.setActive(activeCluster);
|
||||||
this.clusters.replace(newClusters);
|
this.clusters.replace(newClusters);
|
||||||
this.removedClusters.replace(removedClusters);
|
this.removedClusters.replace(removedClusters);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import migrations from "../migrations/hotbar-store";
|
|||||||
import * as uuid from "uuid";
|
import * as uuid from "uuid";
|
||||||
import { toJS } from "./utils";
|
import { toJS } from "./utils";
|
||||||
import { CatalogEntityItem } from "../renderer/components/+catalog/catalog-entity.store";
|
import { CatalogEntityItem } from "../renderer/components/+catalog/catalog-entity.store";
|
||||||
import { CatalogEntity } from "./catalog/catalog-entity";
|
|
||||||
import isNull from "lodash/isNull";
|
import isNull from "lodash/isNull";
|
||||||
|
|
||||||
export interface HotbarItem {
|
export interface HotbarItem {
|
||||||
@ -150,9 +149,9 @@ export class HotbarStore extends BaseStore<HotbarStoreModel> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
removeFromHotbar(item: CatalogEntity) {
|
removeFromHotbar(uid: string) {
|
||||||
const hotbar = this.getActive();
|
const hotbar = this.getActive();
|
||||||
const index = hotbar.items.findIndex((i) => i?.entity.uid === item.getId());
|
const index = hotbar.items.findIndex((i) => i?.entity.uid === uid);
|
||||||
|
|
||||||
if (index == -1) {
|
if (index == -1) {
|
||||||
return;
|
return;
|
||||||
@ -161,6 +160,40 @@ export class HotbarStore extends BaseStore<HotbarStoreModel> {
|
|||||||
hotbar.items[index] = null;
|
hotbar.items[index] = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findClosestEmptyIndex(from: number, direction = 1) {
|
||||||
|
let index = from;
|
||||||
|
|
||||||
|
while(this.getActive().items[index] != null) {
|
||||||
|
index += direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
restackItems(from: number, to: number): void {
|
||||||
|
const { items } = this.getActive();
|
||||||
|
const source = items[from];
|
||||||
|
const moveDown = from < to;
|
||||||
|
|
||||||
|
if (from < 0 || to < 0 || from >= items.length || to >= items.length || isNaN(from) || isNaN(to)) {
|
||||||
|
throw new Error("Invalid 'from' or 'to' arguments");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from == to) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.splice(from, 1, null);
|
||||||
|
|
||||||
|
if (items[to] == null) {
|
||||||
|
items.splice(to, 1, source);
|
||||||
|
} else {
|
||||||
|
// Move cells up or down to closes empty cell
|
||||||
|
items.splice(this.findClosestEmptyIndex(to, moveDown ? -1 : 1), 1);
|
||||||
|
items.splice(to, 0, source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switchToPrevious() {
|
switchToPrevious() {
|
||||||
const hotbarStore = HotbarStore.getInstance();
|
const hotbarStore = HotbarStore.getInstance();
|
||||||
let index = hotbarStore.activeHotbarIndex - 1;
|
let index = hotbarStore.activeHotbarIndex - 1;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// Lens-extensions api developer's kit
|
// Lens-extensions api developer's kit
|
||||||
export * from "../lens-main-extension";
|
export { LensMainExtension } from "../lens-main-extension";
|
||||||
export * from "../lens-renderer-extension";
|
export { LensRendererExtension } from "../lens-renderer-extension";
|
||||||
|
|
||||||
// APIs
|
// APIs
|
||||||
import * as App from "./app";
|
import * as App from "./app";
|
||||||
|
|||||||
@ -31,15 +31,8 @@ jest.mock("request-promise-native");
|
|||||||
import { Console } from "console";
|
import { Console } from "console";
|
||||||
import mockFs from "mock-fs";
|
import mockFs from "mock-fs";
|
||||||
import { Cluster } from "../cluster";
|
import { Cluster } from "../cluster";
|
||||||
import { ContextHandler } from "../context-handler";
|
|
||||||
import { getFreePort } from "../port";
|
|
||||||
import { V1ResourceAttributes } from "@kubernetes/client-node";
|
|
||||||
import { apiResources } from "../../common/rbac";
|
|
||||||
import request from "request-promise-native";
|
|
||||||
import { Kubectl } from "../kubectl";
|
import { Kubectl } from "../kubectl";
|
||||||
|
|
||||||
const mockedRequest = request as jest.MockedFunction<typeof request>;
|
|
||||||
|
|
||||||
console = new Console(process.stdout, process.stderr); // fix mockFS
|
console = new Console(process.stdout, process.stderr); // fix mockFS
|
||||||
|
|
||||||
describe("create clusters", () => {
|
describe("create clusters", () => {
|
||||||
@ -99,54 +92,7 @@ describe("create clusters", () => {
|
|||||||
expect(() => c.disconnect()).not.toThrowError();
|
expect(() => c.disconnect()).not.toThrowError();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("init should not throw if everything is in order", async () => {
|
|
||||||
await c.init(await getFreePort());
|
|
||||||
expect(logger.info).toBeCalledWith(expect.stringContaining("init success"), {
|
|
||||||
id: "foo",
|
|
||||||
apiUrl: "https://192.168.64.3:8443",
|
|
||||||
context: "minikube",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("activating cluster should try to connect to cluster and do a refresh", async () => {
|
it("activating cluster should try to connect to cluster and do a refresh", async () => {
|
||||||
const port = await getFreePort();
|
|
||||||
|
|
||||||
jest.spyOn(ContextHandler.prototype, "ensureServer");
|
|
||||||
|
|
||||||
const mockListNSs = jest.fn();
|
|
||||||
const mockKC = {
|
|
||||||
makeApiClient() {
|
|
||||||
return {
|
|
||||||
listNamespace: mockListNSs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
jest.spyOn(Cluster.prototype, "isClusterAdmin").mockReturnValue(Promise.resolve(true));
|
|
||||||
jest.spyOn(Cluster.prototype, "canUseWatchApi").mockReturnValue(Promise.resolve(true));
|
|
||||||
jest.spyOn(Cluster.prototype, "canI")
|
|
||||||
.mockImplementation((attr: V1ResourceAttributes): Promise<boolean> => {
|
|
||||||
expect(attr.namespace).toBe("default");
|
|
||||||
expect(attr.verb).toBe("list");
|
|
||||||
|
|
||||||
return Promise.resolve(true);
|
|
||||||
});
|
|
||||||
jest.spyOn(Cluster.prototype, "getProxyKubeconfig").mockReturnValue(mockKC as any);
|
|
||||||
mockListNSs.mockImplementationOnce(() => ({
|
|
||||||
body: {
|
|
||||||
items: [{
|
|
||||||
metadata: {
|
|
||||||
name: "default",
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
mockedRequest.mockImplementationOnce(((uri: any) => {
|
|
||||||
expect(uri).toBe(`http://localhost:${port}/api-kube/version`);
|
|
||||||
|
|
||||||
return Promise.resolve({ gitVersion: "1.2.3" });
|
|
||||||
}) as any);
|
|
||||||
|
|
||||||
const c = new class extends Cluster {
|
const c = new class extends Cluster {
|
||||||
// only way to mock protected methods, without these we leak promises
|
// only way to mock protected methods, without these we leak promises
|
||||||
@ -162,14 +108,20 @@ describe("create clusters", () => {
|
|||||||
kubeConfigPath: "minikube-config.yml"
|
kubeConfigPath: "minikube-config.yml"
|
||||||
});
|
});
|
||||||
|
|
||||||
await c.init(port);
|
c.contextHandler = {
|
||||||
|
ensureServer: jest.fn(),
|
||||||
|
stopServer: jest.fn()
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
jest.spyOn(c, "reconnect");
|
||||||
|
jest.spyOn(c, "canI");
|
||||||
|
jest.spyOn(c, "refreshConnectionStatus");
|
||||||
|
|
||||||
await c.activate();
|
await c.activate();
|
||||||
|
|
||||||
expect(ContextHandler.prototype.ensureServer).toBeCalled();
|
expect(c.reconnect).toBeCalled();
|
||||||
expect(mockedRequest).toBeCalled();
|
expect(c.refreshConnectionStatus).toBeCalled();
|
||||||
expect(c.accessible).toBe(true);
|
|
||||||
expect(c.allowedNamespaces.length).toBe(1);
|
|
||||||
expect(c.allowedResources.length).toBe(apiResources.length);
|
|
||||||
c.disconnect();
|
c.disconnect();
|
||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -26,10 +26,10 @@ jest.mock("winston", () => ({
|
|||||||
jest.mock("../../common/ipc");
|
jest.mock("../../common/ipc");
|
||||||
jest.mock("child_process");
|
jest.mock("child_process");
|
||||||
jest.mock("tcp-port-used");
|
jest.mock("tcp-port-used");
|
||||||
|
//jest.mock("../utils/get-port");
|
||||||
|
|
||||||
import { Cluster } from "../cluster";
|
import { Cluster } from "../cluster";
|
||||||
import { KubeAuthProxy } from "../kube-auth-proxy";
|
import { KubeAuthProxy } from "../kube-auth-proxy";
|
||||||
import { getFreePort } from "../port";
|
|
||||||
import { broadcastMessage } from "../../common/ipc";
|
import { broadcastMessage } from "../../common/ipc";
|
||||||
import { ChildProcess, spawn } from "child_process";
|
import { ChildProcess, spawn } from "child_process";
|
||||||
import { bundledKubectlPath, Kubectl } from "../kubectl";
|
import { bundledKubectlPath, Kubectl } from "../kubectl";
|
||||||
@ -39,6 +39,7 @@ import { Readable } from "stream";
|
|||||||
import { UserStore } from "../../common/user-store";
|
import { UserStore } from "../../common/user-store";
|
||||||
import { Console } from "console";
|
import { Console } from "console";
|
||||||
import { stdout, stderr } from "process";
|
import { stdout, stderr } from "process";
|
||||||
|
import mockFs from "mock-fs";
|
||||||
|
|
||||||
console = new Console(stdout, stderr);
|
console = new Console(stdout, stderr);
|
||||||
|
|
||||||
@ -51,11 +52,41 @@ describe("kube auth proxy tests", () => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
UserStore.resetInstance();
|
UserStore.resetInstance();
|
||||||
UserStore.createInstance();
|
UserStore.createInstance();
|
||||||
|
|
||||||
|
const mockMinikubeConfig = {
|
||||||
|
"minikube-config.yml": JSON.stringify({
|
||||||
|
apiVersion: "v1",
|
||||||
|
clusters: [{
|
||||||
|
name: "minikube",
|
||||||
|
cluster: {
|
||||||
|
server: "https://192.168.64.3:8443",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
"current-context": "minikube",
|
||||||
|
contexts: [{
|
||||||
|
context: {
|
||||||
|
cluster: "minikube",
|
||||||
|
user: "minikube",
|
||||||
|
},
|
||||||
|
name: "minikube",
|
||||||
|
}],
|
||||||
|
users: [{
|
||||||
|
name: "minikube",
|
||||||
|
}],
|
||||||
|
kind: "Config",
|
||||||
|
preferences: {},
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFs(mockMinikubeConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockFs.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calling exit multiple times shouldn't throw", async () => {
|
it("calling exit multiple times shouldn't throw", async () => {
|
||||||
const port = await getFreePort();
|
const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "minikube-config.yml", contextName: "minikube" }), {});
|
||||||
const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {});
|
|
||||||
|
|
||||||
kap.exit();
|
kap.exit();
|
||||||
kap.exit();
|
kap.exit();
|
||||||
@ -63,13 +94,11 @@ describe("kube auth proxy tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("spawn tests", () => {
|
describe("spawn tests", () => {
|
||||||
let port: number;
|
|
||||||
let mockedCP: MockProxy<ChildProcess>;
|
let mockedCP: MockProxy<ChildProcess>;
|
||||||
let listeners: Record<string, (...args: any[]) => void>;
|
let listeners: Record<string, (...args: any[]) => void>;
|
||||||
let proxy: KubeAuthProxy;
|
let proxy: KubeAuthProxy;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
port = await getFreePort();
|
|
||||||
mockedCP = mock<ChildProcess>();
|
mockedCP = mock<ChildProcess>();
|
||||||
listeners = {};
|
listeners = {};
|
||||||
|
|
||||||
@ -89,6 +118,7 @@ describe("kube auth proxy tests", () => {
|
|||||||
mockedCP.stdout = mock<Readable>();
|
mockedCP.stdout = mock<Readable>();
|
||||||
mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
|
mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
|
||||||
listeners[`stdout/${event}`] = listener;
|
listeners[`stdout/${event}`] = listener;
|
||||||
|
listeners[`stdout/${event}`]("Starting to serve on 127.0.0.1:9191");
|
||||||
|
|
||||||
return mockedCP.stdout;
|
return mockedCP.stdout;
|
||||||
});
|
});
|
||||||
@ -98,10 +128,10 @@ describe("kube auth proxy tests", () => {
|
|||||||
return mockedCP;
|
return mockedCP;
|
||||||
});
|
});
|
||||||
mockWaitUntilUsed.mockReturnValueOnce(Promise.resolve());
|
mockWaitUntilUsed.mockReturnValueOnce(Promise.resolve());
|
||||||
const cluster = new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" });
|
|
||||||
|
|
||||||
jest.spyOn(cluster, "apiUrl", "get").mockReturnValue("https://fake.k8s.internal");
|
const cluster = new Cluster({ id: "foobar", kubeConfigPath: "minikube-config.yml", contextName: "minikube" });
|
||||||
proxy = new KubeAuthProxy(cluster, port, {});
|
|
||||||
|
proxy = new KubeAuthProxy(cluster, {});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should call spawn and broadcast errors", async () => {
|
it("should call spawn and broadcast errors", async () => {
|
||||||
@ -127,7 +157,6 @@ describe("kube auth proxy tests", () => {
|
|||||||
|
|
||||||
it("should call spawn and broadcast stdout serving info", async () => {
|
it("should call spawn and broadcast stdout serving info", async () => {
|
||||||
await proxy.run();
|
await proxy.run();
|
||||||
listeners["stdout/data"]("Starting to serve on");
|
|
||||||
|
|
||||||
expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "Authentication proxy started\n" });
|
expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "Authentication proxy started\n" });
|
||||||
});
|
});
|
||||||
|
|||||||
@ -27,7 +27,6 @@ import { KubeconfigManager } from "../kubeconfig-manager";
|
|||||||
import mockFs from "mock-fs";
|
import mockFs from "mock-fs";
|
||||||
import { Cluster } from "../cluster";
|
import { Cluster } from "../cluster";
|
||||||
import { ContextHandler } from "../context-handler";
|
import { ContextHandler } from "../context-handler";
|
||||||
import { getFreePort } from "../port";
|
|
||||||
import fse from "fs-extra";
|
import fse from "fs-extra";
|
||||||
import { loadYaml } from "@kubernetes/client-node";
|
import { loadYaml } from "@kubernetes/client-node";
|
||||||
import { Console } from "console";
|
import { Console } from "console";
|
||||||
@ -36,6 +35,9 @@ import * as path from "path";
|
|||||||
console = new Console(process.stdout, process.stderr); // fix mockFS
|
console = new Console(process.stdout, process.stderr); // fix mockFS
|
||||||
|
|
||||||
describe("kubeconfig manager tests", () => {
|
describe("kubeconfig manager tests", () => {
|
||||||
|
let cluster: Cluster;
|
||||||
|
let contextHandler: ContextHandler;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const mockOpts = {
|
const mockOpts = {
|
||||||
"minikube-config.yml": JSON.stringify({
|
"minikube-config.yml": JSON.stringify({
|
||||||
@ -62,6 +64,14 @@ describe("kubeconfig manager tests", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
mockFs(mockOpts);
|
mockFs(mockOpts);
|
||||||
|
|
||||||
|
cluster = new Cluster({
|
||||||
|
id: "foo",
|
||||||
|
contextName: "minikube",
|
||||||
|
kubeConfigPath: "minikube-config.yml",
|
||||||
|
});
|
||||||
|
contextHandler = jest.fn() as any;
|
||||||
|
jest.spyOn(KubeconfigManager.prototype, "resolveProxyUrl", "get").mockReturnValue("http://127.0.0.1:9191/foo");
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -69,14 +79,7 @@ describe("kubeconfig manager tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should create 'temp' kube config with proxy", async () => {
|
it("should create 'temp' kube config with proxy", async () => {
|
||||||
const cluster = new Cluster({
|
const kubeConfManager = new KubeconfigManager(cluster, contextHandler);
|
||||||
id: "foo",
|
|
||||||
contextName: "minikube",
|
|
||||||
kubeConfigPath: "minikube-config.yml",
|
|
||||||
});
|
|
||||||
const contextHandler = new ContextHandler(cluster);
|
|
||||||
const port = await getFreePort();
|
|
||||||
const kubeConfManager = new KubeconfigManager(cluster, contextHandler, port);
|
|
||||||
|
|
||||||
expect(logger.error).not.toBeCalled();
|
expect(logger.error).not.toBeCalled();
|
||||||
expect(await kubeConfManager.getPath()).toBe(`tmp${path.sep}kubeconfig-foo`);
|
expect(await kubeConfManager.getPath()).toBe(`tmp${path.sep}kubeconfig-foo`);
|
||||||
@ -86,19 +89,12 @@ describe("kubeconfig manager tests", () => {
|
|||||||
const yml = loadYaml<any>(file.toString());
|
const yml = loadYaml<any>(file.toString());
|
||||||
|
|
||||||
expect(yml["current-context"]).toBe("minikube");
|
expect(yml["current-context"]).toBe("minikube");
|
||||||
expect(yml["clusters"][0]["cluster"]["server"]).toBe(`http://127.0.0.1:${port}/foo`);
|
expect(yml["clusters"][0]["cluster"]["server"].endsWith("/foo")).toBe(true);
|
||||||
expect(yml["users"][0]["name"]).toBe("proxy");
|
expect(yml["users"][0]["name"]).toBe("proxy");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should remove 'temp' kube config on unlink and remove reference from inside class", async () => {
|
it("should remove 'temp' kube config on unlink and remove reference from inside class", async () => {
|
||||||
const cluster = new Cluster({
|
const kubeConfManager = new KubeconfigManager(cluster, contextHandler);
|
||||||
id: "foo",
|
|
||||||
contextName: "minikube",
|
|
||||||
kubeConfigPath: "minikube-config.yml",
|
|
||||||
});
|
|
||||||
const contextHandler = new ContextHandler(cluster);
|
|
||||||
const port = await getFreePort();
|
|
||||||
const kubeConfManager = new KubeconfigManager(cluster, contextHandler, port);
|
|
||||||
const configPath = await kubeConfManager.getPath();
|
const configPath = await kubeConfManager.getPath();
|
||||||
|
|
||||||
expect(await fse.pathExists(configPath)).toBe(true);
|
expect(await fse.pathExists(configPath)).toBe(true);
|
||||||
|
|||||||
@ -5,10 +5,12 @@ import { Cluster } from "../../cluster";
|
|||||||
import { computeDiff, configToModels } from "../kubeconfig-sync";
|
import { computeDiff, configToModels } from "../kubeconfig-sync";
|
||||||
import mockFs from "mock-fs";
|
import mockFs from "mock-fs";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import { ClusterStore } from "../../../common/cluster-store";
|
||||||
|
|
||||||
describe("kubeconfig-sync.source tests", () => {
|
describe("kubeconfig-sync.source tests", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFs();
|
mockFs();
|
||||||
|
ClusterStore.createInstance();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -57,10 +59,9 @@ describe("kubeconfig-sync.source tests", () => {
|
|||||||
it("should leave an empty source empty if there are no entries", () => {
|
it("should leave an empty source empty if there are no entries", () => {
|
||||||
const contents = "";
|
const contents = "";
|
||||||
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
|
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
|
||||||
const port = 0;
|
|
||||||
const filePath = "/bar";
|
const filePath = "/bar";
|
||||||
|
|
||||||
computeDiff(contents, rootSource, port, filePath);
|
computeDiff(contents, rootSource, filePath);
|
||||||
|
|
||||||
expect(rootSource.size).toBe(0);
|
expect(rootSource.size).toBe(0);
|
||||||
});
|
});
|
||||||
@ -93,12 +94,11 @@ describe("kubeconfig-sync.source tests", () => {
|
|||||||
currentContext: "foobar"
|
currentContext: "foobar"
|
||||||
});
|
});
|
||||||
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
|
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
|
||||||
const port = 0;
|
|
||||||
const filePath = "/bar";
|
const filePath = "/bar";
|
||||||
|
|
||||||
fs.writeFileSync(filePath, contents);
|
fs.writeFileSync(filePath, contents);
|
||||||
|
|
||||||
computeDiff(contents, rootSource, port, filePath);
|
computeDiff(contents, rootSource, filePath);
|
||||||
|
|
||||||
expect(rootSource.size).toBe(1);
|
expect(rootSource.size).toBe(1);
|
||||||
|
|
||||||
@ -137,12 +137,11 @@ describe("kubeconfig-sync.source tests", () => {
|
|||||||
currentContext: "foobar"
|
currentContext: "foobar"
|
||||||
});
|
});
|
||||||
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
|
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
|
||||||
const port = 0;
|
|
||||||
const filePath = "/bar";
|
const filePath = "/bar";
|
||||||
|
|
||||||
fs.writeFileSync(filePath, contents);
|
fs.writeFileSync(filePath, contents);
|
||||||
|
|
||||||
computeDiff(contents, rootSource, port, filePath);
|
computeDiff(contents, rootSource, filePath);
|
||||||
|
|
||||||
expect(rootSource.size).toBe(1);
|
expect(rootSource.size).toBe(1);
|
||||||
|
|
||||||
@ -151,7 +150,7 @@ describe("kubeconfig-sync.source tests", () => {
|
|||||||
expect(c.kubeConfigPath).toBe("/bar");
|
expect(c.kubeConfigPath).toBe("/bar");
|
||||||
expect(c.contextName).toBe("context-name");
|
expect(c.contextName).toBe("context-name");
|
||||||
|
|
||||||
computeDiff("{}", rootSource, port, filePath);
|
computeDiff("{}", rootSource, filePath);
|
||||||
|
|
||||||
expect(rootSource.size).toBe(0);
|
expect(rootSource.size).toBe(0);
|
||||||
});
|
});
|
||||||
@ -192,12 +191,11 @@ describe("kubeconfig-sync.source tests", () => {
|
|||||||
currentContext: "foobar"
|
currentContext: "foobar"
|
||||||
});
|
});
|
||||||
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
|
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
|
||||||
const port = 0;
|
|
||||||
const filePath = "/bar";
|
const filePath = "/bar";
|
||||||
|
|
||||||
fs.writeFileSync(filePath, contents);
|
fs.writeFileSync(filePath, contents);
|
||||||
|
|
||||||
computeDiff(contents, rootSource, port, filePath);
|
computeDiff(contents, rootSource, filePath);
|
||||||
|
|
||||||
expect(rootSource.size).toBe(2);
|
expect(rootSource.size).toBe(2);
|
||||||
|
|
||||||
@ -237,7 +235,7 @@ describe("kubeconfig-sync.source tests", () => {
|
|||||||
currentContext: "foobar"
|
currentContext: "foobar"
|
||||||
});
|
});
|
||||||
|
|
||||||
computeDiff(newContents, rootSource, port, filePath);
|
computeDiff(newContents, rootSource, filePath);
|
||||||
|
|
||||||
expect(rootSource.size).toBe(1);
|
expect(rootSource.size).toBe(1);
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { CatalogEntity, catalogEntityRegistry } from "../../common/catalog";
|
|||||||
import { watch } from "chokidar";
|
import { watch } from "chokidar";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import fse from "fs-extra";
|
import fse from "fs-extra";
|
||||||
import * as uuid from "uuid";
|
|
||||||
import stream from "stream";
|
import stream from "stream";
|
||||||
import { Disposer, ExtendedObservableMap, iter, Singleton } from "../../common/utils";
|
import { Disposer, ExtendedObservableMap, iter, Singleton } from "../../common/utils";
|
||||||
import logger from "../logger";
|
import logger from "../logger";
|
||||||
@ -13,6 +12,7 @@ import { Cluster } from "../cluster";
|
|||||||
import { catalogEntityFromCluster } from "../cluster-manager";
|
import { catalogEntityFromCluster } from "../cluster-manager";
|
||||||
import { UserStore } from "../../common/user-store";
|
import { UserStore } from "../../common/user-store";
|
||||||
import { ClusterStore, UpdateClusterModel } from "../../common/cluster-store";
|
import { ClusterStore, UpdateClusterModel } from "../../common/cluster-store";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
|
||||||
const logPrefix = "[KUBECONFIG-SYNC]:";
|
const logPrefix = "[KUBECONFIG-SYNC]:";
|
||||||
|
|
||||||
@ -24,7 +24,7 @@ export class KubeconfigSyncManager extends Singleton {
|
|||||||
protected static readonly syncName = "lens:kube-sync";
|
protected static readonly syncName = "lens:kube-sync";
|
||||||
|
|
||||||
@action
|
@action
|
||||||
startSync(port: number): void {
|
startSync(): void {
|
||||||
if (this.syncing) {
|
if (this.syncing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -41,16 +41,16 @@ export class KubeconfigSyncManager extends Singleton {
|
|||||||
)));
|
)));
|
||||||
|
|
||||||
// This must be done so that c&p-ed clusters are visible
|
// This must be done so that c&p-ed clusters are visible
|
||||||
this.startNewSync(ClusterStore.storedKubeConfigFolder, port);
|
this.startNewSync(ClusterStore.storedKubeConfigFolder);
|
||||||
|
|
||||||
for (const filePath of UserStore.getInstance().syncKubeconfigEntries.keys()) {
|
for (const filePath of UserStore.getInstance().syncKubeconfigEntries.keys()) {
|
||||||
this.startNewSync(filePath, port);
|
this.startNewSync(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.syncListDisposer = UserStore.getInstance().syncKubeconfigEntries.observe_(change => {
|
this.syncListDisposer = UserStore.getInstance().syncKubeconfigEntries.observe_(change => {
|
||||||
switch (change.type) {
|
switch (change.type) {
|
||||||
case "add":
|
case "add":
|
||||||
this.startNewSync(change.name, port);
|
this.startNewSync(change.name);
|
||||||
break;
|
break;
|
||||||
case "delete":
|
case "delete":
|
||||||
this.stopOldSync(change.name);
|
this.stopOldSync(change.name);
|
||||||
@ -72,14 +72,14 @@ export class KubeconfigSyncManager extends Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
protected async startNewSync(filePath: string, port: number): Promise<void> {
|
protected async startNewSync(filePath: string): Promise<void> {
|
||||||
if (this.sources.has(filePath)) {
|
if (this.sources.has(filePath)) {
|
||||||
// don't start a new sync if we already have one
|
// don't start a new sync if we already have one
|
||||||
return void logger.debug(`${logPrefix} already syncing file/folder`, { filePath });
|
return void logger.debug(`${logPrefix} already syncing file/folder`, { filePath });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.sources.set(filePath, await watchFileChanges(filePath, port));
|
this.sources.set(filePath, await watchFileChanges(filePath));
|
||||||
|
|
||||||
logger.info(`${logPrefix} starting sync of file/folder`, { filePath });
|
logger.info(`${logPrefix} starting sync of file/folder`, { filePath });
|
||||||
logger.debug(`${logPrefix} ${this.sources.size} files/folders watched`, { files: Array.from(this.sources.keys()) });
|
logger.debug(`${logPrefix} ${this.sources.size} files/folders watched`, { files: Array.from(this.sources.keys()) });
|
||||||
@ -124,7 +124,7 @@ type RootSourceValue = [Cluster, CatalogEntity];
|
|||||||
type RootSource = ObservableMap<string, RootSourceValue>;
|
type RootSource = ObservableMap<string, RootSourceValue>;
|
||||||
|
|
||||||
// exported for testing
|
// exported for testing
|
||||||
export function computeDiff(contents: string, source: RootSource, port: number, filePath: string): void {
|
export function computeDiff(contents: string, source: RootSource, filePath: string): void {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
try {
|
try {
|
||||||
const rawModels = configToModels(loadConfigFromString(contents), filePath);
|
const rawModels = configToModels(loadConfigFromString(contents), filePath);
|
||||||
@ -156,14 +156,13 @@ export function computeDiff(contents: string, source: RootSource, port: number,
|
|||||||
for (const [contextName, model] of models) {
|
for (const [contextName, model] of models) {
|
||||||
// add new clusters to the source
|
// add new clusters to the source
|
||||||
try {
|
try {
|
||||||
const cluster = new Cluster({ ...model, id: uuid.v4() });
|
const clusterId = createHash("md5").update(`${filePath}:${contextName}`).digest("hex");
|
||||||
|
const cluster = ClusterStore.getInstance().getById(clusterId) || new Cluster({ ...model, id: clusterId});
|
||||||
|
|
||||||
if (!cluster.apiUrl) {
|
if (!cluster.apiUrl) {
|
||||||
throw new Error("Cluster constructor failed, see above error");
|
throw new Error("Cluster constructor failed, see above error");
|
||||||
}
|
}
|
||||||
|
|
||||||
cluster.init(port);
|
|
||||||
|
|
||||||
const entity = catalogEntityFromCluster(cluster);
|
const entity = catalogEntityFromCluster(cluster);
|
||||||
|
|
||||||
entity.metadata.labels.file = filePath;
|
entity.metadata.labels.file = filePath;
|
||||||
@ -181,7 +180,7 @@ export function computeDiff(contents: string, source: RootSource, port: number,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function diffChangedConfig(filePath: string, source: RootSource, port: number): Disposer {
|
function diffChangedConfig(filePath: string, source: RootSource): Disposer {
|
||||||
logger.debug(`${logPrefix} file changed`, { filePath });
|
logger.debug(`${logPrefix} file changed`, { filePath });
|
||||||
|
|
||||||
// TODO: replace with an AbortController with fs.readFile when we upgrade to Node 16 (after it comes out)
|
// TODO: replace with an AbortController with fs.readFile when we upgrade to Node 16 (after it comes out)
|
||||||
@ -214,14 +213,14 @@ function diffChangedConfig(filePath: string, source: RootSource, port: number):
|
|||||||
})
|
})
|
||||||
.on("end", () => {
|
.on("end", () => {
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
computeDiff(Buffer.concat(bufs).toString("utf-8"), source, port, filePath);
|
computeDiff(Buffer.concat(bufs).toString("utf-8"), source, filePath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return cleanup;
|
return cleanup;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function watchFileChanges(filePath: string, port: number): Promise<[IComputedValue<CatalogEntity[]>, Disposer]> {
|
async function watchFileChanges(filePath: string): Promise<[IComputedValue<CatalogEntity[]>, Disposer]> {
|
||||||
const stat = await fse.stat(filePath); // traverses symlinks, is a race condition
|
const stat = await fse.stat(filePath); // traverses symlinks, is a race condition
|
||||||
const watcher = watch(filePath, {
|
const watcher = watch(filePath, {
|
||||||
followSymlinks: true,
|
followSymlinks: true,
|
||||||
@ -235,10 +234,10 @@ async function watchFileChanges(filePath: string, port: number): Promise<[ICompu
|
|||||||
watcher
|
watcher
|
||||||
.on("change", (childFilePath) => {
|
.on("change", (childFilePath) => {
|
||||||
stoppers.get(childFilePath)();
|
stoppers.get(childFilePath)();
|
||||||
stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrDefault(childFilePath), port));
|
stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrDefault(childFilePath)));
|
||||||
})
|
})
|
||||||
.on("add", (childFilePath) => {
|
.on("add", (childFilePath) => {
|
||||||
stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrDefault(childFilePath), port));
|
stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrDefault(childFilePath)));
|
||||||
})
|
})
|
||||||
.on("unlink", (childFilePath) => {
|
.on("unlink", (childFilePath) => {
|
||||||
stoppers.get(childFilePath)();
|
stoppers.get(childFilePath)();
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import request, { RequestPromiseOptions } from "request-promise-native";
|
import { RequestPromiseOptions } from "request-promise-native";
|
||||||
import { Cluster } from "../cluster";
|
import { Cluster } from "../cluster";
|
||||||
|
import { k8sRequest } from "../k8s-request";
|
||||||
|
|
||||||
export type ClusterDetectionResult = {
|
export type ClusterDetectionResult = {
|
||||||
value: string | number | boolean
|
value: string | number | boolean
|
||||||
@ -7,11 +8,9 @@ export type ClusterDetectionResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class BaseClusterDetector {
|
export class BaseClusterDetector {
|
||||||
cluster: Cluster;
|
|
||||||
key: string;
|
key: string;
|
||||||
|
|
||||||
constructor(cluster: Cluster) {
|
constructor(public cluster: Cluster) {
|
||||||
this.cluster = cluster;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
detect(): Promise<ClusterDetectionResult> {
|
detect(): Promise<ClusterDetectionResult> {
|
||||||
@ -19,16 +18,6 @@ export class BaseClusterDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected async k8sRequest<T = any>(path: string, options: RequestPromiseOptions = {}): Promise<T> {
|
protected async k8sRequest<T = any>(path: string, options: RequestPromiseOptions = {}): Promise<T> {
|
||||||
const apiUrl = this.cluster.kubeProxyUrl + path;
|
return k8sRequest(this.cluster, path, options);
|
||||||
|
|
||||||
return request(apiUrl, {
|
|
||||||
json: true,
|
|
||||||
timeout: 30000,
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
Host: `${this.cluster.id}.${new URL(this.cluster.kubeProxyUrl).host}`, // required in ClusterManager.getClusterForRequest()
|
|
||||||
...(options.headers || {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,45 +1,28 @@
|
|||||||
import "../common/cluster-ipc";
|
import "../common/cluster-ipc";
|
||||||
import type http from "http";
|
import type http from "http";
|
||||||
import { ipcMain } from "electron";
|
import { ipcMain } from "electron";
|
||||||
import { action, autorun, makeObservable, observable, reaction } from "mobx";
|
import { action, autorun, reaction } from "mobx";
|
||||||
import { Singleton } from "../common/utils";
|
|
||||||
import { ClusterStore, getClusterIdFromHost } from "../common/cluster-store";
|
import { ClusterStore, getClusterIdFromHost } from "../common/cluster-store";
|
||||||
import { Cluster } from "./cluster";
|
import { Cluster } from "./cluster";
|
||||||
import logger from "./logger";
|
import logger from "./logger";
|
||||||
import { apiKubePrefix } from "../common/vars";
|
import { apiKubePrefix } from "../common/vars";
|
||||||
import { CatalogEntity, catalogEntityRegistry } from "../common/catalog";
|
import { Singleton, toJS } from "../common/utils";
|
||||||
|
import { catalogEntityRegistry } from "../common/catalog";
|
||||||
import { KubernetesCluster } from "../common/catalog-entities/kubernetes-cluster";
|
import { KubernetesCluster } from "../common/catalog-entities/kubernetes-cluster";
|
||||||
|
|
||||||
const clusterOwnerRef = "ClusterManager";
|
|
||||||
|
|
||||||
export class ClusterManager extends Singleton {
|
export class ClusterManager extends Singleton {
|
||||||
catalogSource = observable.array<CatalogEntity>([]);
|
constructor() {
|
||||||
|
|
||||||
constructor(public readonly port: number) {
|
|
||||||
super();
|
super();
|
||||||
|
|
||||||
makeObservable(this);
|
reaction(() => toJS(ClusterStore.getInstance().clustersList), () => {
|
||||||
|
this.updateCatalog(ClusterStore.getInstance().clustersList);
|
||||||
catalogEntityRegistry.addObservableSource("lens:kubernetes-clusters", this.catalogSource);
|
|
||||||
// auto-init clusters
|
|
||||||
reaction(() => ClusterStore.getInstance().enabledClustersList, (clusters) => {
|
|
||||||
clusters.forEach((cluster) => {
|
|
||||||
if (!cluster.initialized && !cluster.initializing) {
|
|
||||||
logger.info(`[CLUSTER-MANAGER]: init cluster`, cluster.getMeta());
|
|
||||||
cluster.init(port);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
}, { fireImmediately: true });
|
|
||||||
|
|
||||||
reaction(() => ClusterStore.getInstance().enabledClustersList, (enabledClusters) => {
|
|
||||||
this.updateCatalogSource(enabledClusters);
|
|
||||||
}, { fireImmediately: true });
|
}, { fireImmediately: true });
|
||||||
|
|
||||||
reaction(() => catalogEntityRegistry.getItemsForApiKind<KubernetesCluster>("entity.k8slens.dev/v1alpha1", "KubernetesCluster"), (entities) => {
|
reaction(() => catalogEntityRegistry.getItemsForApiKind<KubernetesCluster>("entity.k8slens.dev/v1alpha1", "KubernetesCluster"), (entities) => {
|
||||||
this.syncClustersFromCatalog(entities);
|
this.syncClustersFromCatalog(entities);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// auto-stop removed clusters
|
// auto-stop removed clusters
|
||||||
autorun(() => {
|
autorun(() => {
|
||||||
const removedClusters = Array.from(ClusterStore.getInstance().removedClusters.values());
|
const removedClusters = Array.from(ClusterStore.getInstance().removedClusters.values());
|
||||||
@ -59,32 +42,20 @@ export class ClusterManager extends Singleton {
|
|||||||
ipcMain.on("network:online", () => { this.onNetworkOnline(); });
|
ipcMain.on("network:online", () => { this.onNetworkOnline(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action protected updateCatalog(clusters: Cluster[]) {
|
||||||
protected updateCatalogSource(clusters: Cluster[]) {
|
|
||||||
this.catalogSource.replace(this.catalogSource.filter(entity => (
|
|
||||||
clusters.find((cluster) => entity.metadata.uid === cluster.id)
|
|
||||||
)));
|
|
||||||
|
|
||||||
for (const cluster of clusters) {
|
for (const cluster of clusters) {
|
||||||
if (cluster.ownerRef) {
|
const index = catalogEntityRegistry.items.findIndex((entity) => entity.metadata.uid === cluster.id);
|
||||||
continue;
|
|
||||||
|
if (index !== -1) {
|
||||||
|
const entity = catalogEntityRegistry.items[index];
|
||||||
|
|
||||||
|
entity.status.phase = cluster.disconnected ? "disconnected" : "connected";
|
||||||
|
entity.status.active = !cluster.disconnected;
|
||||||
|
|
||||||
|
if (cluster.preferences?.clusterName) {
|
||||||
|
entity.metadata.name = cluster.preferences.clusterName;
|
||||||
}
|
}
|
||||||
|
catalogEntityRegistry.items.splice(index, 1, entity);
|
||||||
const entityIndex = this.catalogSource.findIndex((entity) => entity.metadata.uid === cluster.id);
|
|
||||||
const newEntity = catalogEntityFromCluster(cluster);
|
|
||||||
|
|
||||||
if (entityIndex === -1) {
|
|
||||||
this.catalogSource.push(newEntity);
|
|
||||||
} else {
|
|
||||||
const oldEntity = this.catalogSource[entityIndex];
|
|
||||||
|
|
||||||
newEntity.status.phase = cluster.disconnected ? "disconnected" : "connected";
|
|
||||||
newEntity.status.active = !cluster.disconnected;
|
|
||||||
newEntity.metadata.labels = {
|
|
||||||
...newEntity.metadata.labels,
|
|
||||||
...oldEntity.metadata.labels
|
|
||||||
};
|
|
||||||
this.catalogSource.splice(entityIndex, 1, newEntity);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -100,8 +71,6 @@ export class ClusterManager extends Singleton {
|
|||||||
if (!cluster) {
|
if (!cluster) {
|
||||||
ClusterStore.getInstance().addCluster({
|
ClusterStore.getInstance().addCluster({
|
||||||
id: entity.metadata.uid,
|
id: entity.metadata.uid,
|
||||||
enabled: true,
|
|
||||||
ownerRef: clusterOwnerRef,
|
|
||||||
preferences: {
|
preferences: {
|
||||||
clusterName: entity.metadata.name
|
clusterName: entity.metadata.name
|
||||||
},
|
},
|
||||||
@ -109,9 +78,6 @@ export class ClusterManager extends Singleton {
|
|||||||
contextName: entity.spec.kubeconfigContext
|
contextName: entity.spec.kubeconfigContext
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
cluster.enabled = true;
|
|
||||||
cluster.ownerRef ||= clusterOwnerRef;
|
|
||||||
cluster.preferences.clusterName = entity.metadata.name;
|
|
||||||
cluster.kubeConfigPath = entity.spec.kubeconfigPath;
|
cluster.kubeConfigPath = entity.spec.kubeconfigPath;
|
||||||
cluster.contextName = entity.spec.kubeconfigContext;
|
cluster.contextName = entity.spec.kubeconfigContext;
|
||||||
|
|
||||||
@ -125,7 +91,7 @@ export class ClusterManager extends Singleton {
|
|||||||
|
|
||||||
protected onNetworkOffline() {
|
protected onNetworkOffline() {
|
||||||
logger.info("[CLUSTER-MANAGER]: network is offline");
|
logger.info("[CLUSTER-MANAGER]: network is offline");
|
||||||
ClusterStore.getInstance().enabledClustersList.forEach((cluster) => {
|
ClusterStore.getInstance().clustersList.forEach((cluster) => {
|
||||||
if (!cluster.disconnected) {
|
if (!cluster.disconnected) {
|
||||||
cluster.online = false;
|
cluster.online = false;
|
||||||
cluster.accessible = false;
|
cluster.accessible = false;
|
||||||
@ -136,7 +102,7 @@ export class ClusterManager extends Singleton {
|
|||||||
|
|
||||||
protected onNetworkOnline() {
|
protected onNetworkOnline() {
|
||||||
logger.info("[CLUSTER-MANAGER]: network is online");
|
logger.info("[CLUSTER-MANAGER]: network is online");
|
||||||
ClusterStore.getInstance().enabledClustersList.forEach((cluster) => {
|
ClusterStore.getInstance().clustersList.forEach((cluster) => {
|
||||||
if (!cluster.disconnected) {
|
if (!cluster.disconnected) {
|
||||||
cluster.refreshConnectionStatus().catch((e) => e);
|
cluster.refreshConnectionStatus().catch((e) => e);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,18 @@
|
|||||||
import { ipcMain } from "electron";
|
import { ipcMain } from "electron";
|
||||||
import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel } from "../common/cluster-store";
|
import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel } from "../common/cluster-store";
|
||||||
import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api";
|
import { action, comparer, computed, observable, reaction, when } from "mobx";
|
||||||
import { action, comparer, computed, makeObservable, observable, reaction, when } from "mobx";
|
import { toJS } from "../common/utils/toJS";
|
||||||
import { apiKubePrefix } from "../common/vars";
|
import { broadcastMessage, ClusterListNamespaceForbiddenChannel } from "../common/ipc";
|
||||||
import { broadcastMessage, ClusterListNamespaceForbiddenChannel, InvalidKubeconfigChannel } from "../common/ipc";
|
|
||||||
import { ContextHandler } from "./context-handler";
|
import { ContextHandler } from "./context-handler";
|
||||||
import { AuthorizationV1Api, CoreV1Api, HttpError, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node";
|
import { AuthorizationV1Api, CoreV1Api, HttpError, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node";
|
||||||
import { Kubectl } from "./kubectl";
|
import { Kubectl } from "./kubectl";
|
||||||
import { KubeconfigManager } from "./kubeconfig-manager";
|
import { KubeconfigManager } from "./kubeconfig-manager";
|
||||||
import { loadConfig, validateKubeConfig } from "../common/kube-helpers";
|
import { loadConfig, validateKubeConfig } from "../common/kube-helpers";
|
||||||
import request, { RequestPromiseOptions } from "request-promise-native";
|
|
||||||
import { apiResources, KubeApiResource } from "../common/rbac";
|
import { apiResources, KubeApiResource } from "../common/rbac";
|
||||||
import logger from "./logger";
|
import logger from "./logger";
|
||||||
import { VersionDetector } from "./cluster-detectors/version-detector";
|
import { VersionDetector } from "./cluster-detectors/version-detector";
|
||||||
import { detectorRegistry } from "./cluster-detectors/detector-registry";
|
import { detectorRegistry } from "./cluster-detectors/detector-registry";
|
||||||
import plimit from "p-limit";
|
import plimit from "p-limit";
|
||||||
import { toJS } from "../common/utils";
|
|
||||||
|
|
||||||
export enum ClusterStatus {
|
export enum ClusterStatus {
|
||||||
AccessGranted = 2,
|
AccessGranted = 2,
|
||||||
@ -37,8 +34,6 @@ export type ClusterRefreshOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface ClusterState {
|
export interface ClusterState {
|
||||||
initialized: boolean;
|
|
||||||
enabled: boolean;
|
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
disconnected: boolean;
|
disconnected: boolean;
|
||||||
@ -71,33 +66,13 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
public contextHandler: ContextHandler;
|
public contextHandler: ContextHandler;
|
||||||
/**
|
|
||||||
* Owner reference
|
|
||||||
*
|
|
||||||
* If extension sets this it needs to also mark cluster as enabled on activate (or when added to a store)
|
|
||||||
*/
|
|
||||||
public ownerRef: string;
|
|
||||||
protected kubeconfigManager: KubeconfigManager;
|
protected kubeconfigManager: KubeconfigManager;
|
||||||
protected eventDisposers: Function[] = [];
|
protected eventDisposers: Function[] = [];
|
||||||
protected activated = false;
|
protected activated = false;
|
||||||
private resourceAccessStatuses: Map<KubeApiResource, boolean> = new Map();
|
private resourceAccessStatuses: Map<KubeApiResource, boolean> = new Map();
|
||||||
|
|
||||||
whenInitialized = when(() => this.initialized);
|
|
||||||
whenReady = when(() => this.ready);
|
whenReady = when(() => this.ready);
|
||||||
|
|
||||||
/**
|
|
||||||
* Is cluster object initializing on-going
|
|
||||||
*
|
|
||||||
* @observable
|
|
||||||
*/
|
|
||||||
@observable initializing = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Is cluster object initialized
|
|
||||||
*
|
|
||||||
* @observable
|
|
||||||
*/
|
|
||||||
@observable initialized = false;
|
|
||||||
/**
|
/**
|
||||||
* Kubeconfig context name
|
* Kubeconfig context name
|
||||||
*
|
*
|
||||||
@ -120,19 +95,6 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
* @observable
|
* @observable
|
||||||
*/
|
*/
|
||||||
@observable apiUrl: string; // cluster server url
|
@observable apiUrl: string; // cluster server url
|
||||||
/**
|
|
||||||
* Internal authentication proxy URL
|
|
||||||
*
|
|
||||||
* @observable
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
@observable kubeProxyUrl: string; // lens-proxy to kube-api url
|
|
||||||
/**
|
|
||||||
* Is cluster instance enabled (disabled clusters are currently hidden)
|
|
||||||
*
|
|
||||||
* @observable
|
|
||||||
*/
|
|
||||||
@observable enabled = false; // only enabled clusters are visible to users
|
|
||||||
/**
|
/**
|
||||||
* Is cluster online
|
* Is cluster online
|
||||||
*
|
*
|
||||||
@ -256,31 +218,29 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
constructor(model: ClusterModel) {
|
constructor(model: ClusterModel) {
|
||||||
makeObservable(this);
|
|
||||||
|
|
||||||
this.id = model.id;
|
this.id = model.id;
|
||||||
this.updateModel(model);
|
this.updateModel(model);
|
||||||
|
|
||||||
try {
|
|
||||||
const kubeconfig = this.getKubeconfig();
|
const kubeconfig = this.getKubeconfig();
|
||||||
const error = validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false});
|
const error = validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server;
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(err);
|
|
||||||
logger.error(`[CLUSTER] Failed to load kubeconfig for the cluster '${this.name || this.contextName}' (context: ${this.contextName}, kubeconfig: ${this.kubeConfigPath}).`);
|
|
||||||
broadcastMessage(InvalidKubeconfigChannel, model.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server;
|
||||||
* Is cluster managed by an extension
|
|
||||||
*/
|
if (ipcMain) {
|
||||||
get isManaged(): boolean {
|
// for the time being, until renderer gets its own cluster type
|
||||||
return !!this.ownerRef;
|
this.contextHandler = new ContextHandler(this);
|
||||||
|
this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler);
|
||||||
|
|
||||||
|
logger.debug(`[CLUSTER]: Cluster init success`, {
|
||||||
|
id: this.id,
|
||||||
|
context: this.contextName,
|
||||||
|
apiUrl: this.apiUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -309,44 +269,11 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
this.metadata = model.metadata;
|
this.metadata = model.metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.ownerRef) {
|
|
||||||
this.ownerRef = model.ownerRef;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (model.accessibleNamespaces) {
|
if (model.accessibleNamespaces) {
|
||||||
this.accessibleNamespaces = model.accessibleNamespaces;
|
this.accessibleNamespaces = model.accessibleNamespaces;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize a cluster (can be done only in main process)
|
|
||||||
*
|
|
||||||
* @param port port where internal auth proxy is listening
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
@action
|
|
||||||
async init(port: number) {
|
|
||||||
try {
|
|
||||||
this.initializing = true;
|
|
||||||
this.contextHandler = new ContextHandler(this);
|
|
||||||
this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler, port);
|
|
||||||
this.kubeProxyUrl = `http://localhost:${port}${apiKubePrefix}`;
|
|
||||||
this.initialized = true;
|
|
||||||
logger.info(`[CLUSTER]: "${this.contextName}" init success`, {
|
|
||||||
id: this.id,
|
|
||||||
context: this.contextName,
|
|
||||||
apiUrl: this.apiUrl
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`[CLUSTER]: init failed: ${err}`, {
|
|
||||||
id: this.id,
|
|
||||||
error: err,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
this.initializing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
@ -385,8 +312,8 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
if (this.activated && !force) {
|
if (this.activated && !force) {
|
||||||
return this.pushState();
|
return this.pushState();
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`[CLUSTER]: activate`, this.getMeta());
|
logger.info(`[CLUSTER]: activate`, this.getMeta());
|
||||||
await this.whenInitialized;
|
|
||||||
|
|
||||||
if (!this.eventDisposers.length) {
|
if (!this.eventDisposers.length) {
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
@ -403,7 +330,7 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
}
|
}
|
||||||
this.activated = true;
|
this.activated = true;
|
||||||
|
|
||||||
return this.pushState();
|
this.pushState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -450,7 +377,6 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
@action
|
@action
|
||||||
async refresh(opts: ClusterRefreshOptions = {}) {
|
async refresh(opts: ClusterRefreshOptions = {}) {
|
||||||
logger.info(`[CLUSTER]: refresh`, this.getMeta());
|
logger.info(`[CLUSTER]: refresh`, this.getMeta());
|
||||||
await this.whenInitialized;
|
|
||||||
await this.refreshConnectionStatus();
|
await this.refreshConnectionStatus();
|
||||||
|
|
||||||
if (this.accessible) {
|
if (this.accessible) {
|
||||||
@ -527,34 +453,6 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
return this.kubeconfigManager.getPath();
|
return this.kubeconfigManager.getPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async k8sRequest<T = any>(path: string, options: RequestPromiseOptions = {}): Promise<T> {
|
|
||||||
options.headers ??= {};
|
|
||||||
options.json ??= true;
|
|
||||||
options.timeout ??= 30000;
|
|
||||||
options.headers.Host = `${this.id}.${new URL(this.kubeProxyUrl).host}`; // required in ClusterManager.getClusterForRequest()
|
|
||||||
|
|
||||||
return request(this.kubeProxyUrl + path, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param prometheusPath path to prometheus service
|
|
||||||
* @param queryParams query parameters
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
getMetrics(prometheusPath: string, queryParams: IMetricsReqParams & { query: string }) {
|
|
||||||
const prometheusPrefix = this.preferences.prometheus?.prefix || "";
|
|
||||||
const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`;
|
|
||||||
|
|
||||||
return this.k8sRequest(metricsPath, {
|
|
||||||
timeout: 0,
|
|
||||||
resolveWithFullResponse: false,
|
|
||||||
json: true,
|
|
||||||
method: "POST",
|
|
||||||
form: queryParams,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async getConnectionStatus(): Promise<ClusterStatus> {
|
protected async getConnectionStatus(): Promise<ClusterStatus> {
|
||||||
try {
|
try {
|
||||||
const versionDetector = new VersionDetector(this);
|
const versionDetector = new VersionDetector(this);
|
||||||
@ -640,25 +538,24 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toJSON(): ClusterModel {
|
toJSON(): ClusterModel {
|
||||||
return toJS({
|
const model: ClusterModel = {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
contextName: this.contextName,
|
contextName: this.contextName,
|
||||||
kubeConfigPath: this.kubeConfigPath,
|
kubeConfigPath: this.kubeConfigPath,
|
||||||
workspace: this.workspace,
|
workspace: this.workspace,
|
||||||
preferences: this.preferences,
|
preferences: this.preferences,
|
||||||
metadata: this.metadata,
|
metadata: this.metadata,
|
||||||
ownerRef: this.ownerRef,
|
|
||||||
accessibleNamespaces: this.accessibleNamespaces,
|
accessibleNamespaces: this.accessibleNamespaces,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
return toJS(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serializable cluster-state used for sync btw main <-> renderer
|
* Serializable cluster-state used for sync btw main <-> renderer
|
||||||
*/
|
*/
|
||||||
getState(): ClusterState {
|
getState(): ClusterState {
|
||||||
return toJS({
|
const state: ClusterState = {
|
||||||
initialized: this.initialized,
|
|
||||||
enabled: this.enabled,
|
|
||||||
apiUrl: this.apiUrl,
|
apiUrl: this.apiUrl,
|
||||||
online: this.online,
|
online: this.online,
|
||||||
ready: this.ready,
|
ready: this.ready,
|
||||||
@ -669,7 +566,9 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
allowedNamespaces: this.allowedNamespaces,
|
allowedNamespaces: this.allowedNamespaces,
|
||||||
allowedResources: this.allowedResources,
|
allowedResources: this.allowedResources,
|
||||||
isGlobalWatchEnabled: this.isGlobalWatchEnabled,
|
isGlobalWatchEnabled: this.isGlobalWatchEnabled,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
return toJS(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -694,7 +593,6 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
name: this.contextName,
|
name: this.contextName,
|
||||||
initialized: this.initialized,
|
|
||||||
ready: this.ready,
|
ready: this.ready,
|
||||||
online: this.online,
|
online: this.online,
|
||||||
accessible: this.accessible,
|
accessible: this.accessible,
|
||||||
|
|||||||
@ -6,16 +6,14 @@ import url, { UrlWithStringQuery } from "url";
|
|||||||
import { CoreV1Api } from "@kubernetes/client-node";
|
import { CoreV1Api } from "@kubernetes/client-node";
|
||||||
import { prometheusProviders } from "../common/prometheus-providers";
|
import { prometheusProviders } from "../common/prometheus-providers";
|
||||||
import logger from "./logger";
|
import logger from "./logger";
|
||||||
import { getFreePort } from "./port";
|
|
||||||
import { KubeAuthProxy } from "./kube-auth-proxy";
|
import { KubeAuthProxy } from "./kube-auth-proxy";
|
||||||
|
|
||||||
export class ContextHandler {
|
export class ContextHandler {
|
||||||
public proxyPort: number;
|
|
||||||
public clusterUrl: UrlWithStringQuery;
|
public clusterUrl: UrlWithStringQuery;
|
||||||
protected kubeAuthProxy: KubeAuthProxy;
|
protected kubeAuthProxy?: KubeAuthProxy;
|
||||||
protected apiTarget: httpProxy.ServerOptions;
|
protected apiTarget?: httpProxy.ServerOptions;
|
||||||
protected prometheusProvider: string;
|
protected prometheusProvider: string;
|
||||||
protected prometheusPath: string;
|
protected prometheusPath: string | null;
|
||||||
|
|
||||||
constructor(protected cluster: Cluster) {
|
constructor(protected cluster: Cluster) {
|
||||||
this.clusterUrl = url.parse(cluster.apiUrl);
|
this.clusterUrl = url.parse(cluster.apiUrl);
|
||||||
@ -77,31 +75,25 @@ export class ContextHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async resolveAuthProxyUrl() {
|
async resolveAuthProxyUrl() {
|
||||||
const proxyPort = await this.ensurePort();
|
await this.ensureServer();
|
||||||
const path = this.clusterUrl.path !== "/" ? this.clusterUrl.path : "";
|
const path = this.clusterUrl.path !== "/" ? this.clusterUrl.path : "";
|
||||||
|
|
||||||
return `http://127.0.0.1:${proxyPort}${path}`;
|
return `http://127.0.0.1:${this.kubeAuthProxy.port}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getApiTarget(isWatchRequest = false): Promise<httpProxy.ServerOptions> {
|
async getApiTarget(isWatchRequest = false): Promise<httpProxy.ServerOptions> {
|
||||||
if (this.apiTarget && !isWatchRequest) {
|
|
||||||
return this.apiTarget;
|
|
||||||
}
|
|
||||||
const timeout = isWatchRequest ? 4 * 60 * 60 * 1000 : 30000; // 4 hours for watch request, 30 seconds for the rest
|
const timeout = isWatchRequest ? 4 * 60 * 60 * 1000 : 30000; // 4 hours for watch request, 30 seconds for the rest
|
||||||
const apiTarget = await this.newApiTarget(timeout);
|
|
||||||
|
|
||||||
if (!isWatchRequest) {
|
if (isWatchRequest) {
|
||||||
this.apiTarget = apiTarget;
|
return this.newApiTarget(timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiTarget;
|
return this.apiTarget ??= await this.newApiTarget(timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async newApiTarget(timeout: number): Promise<httpProxy.ServerOptions> {
|
protected async newApiTarget(timeout: number): Promise<httpProxy.ServerOptions> {
|
||||||
const proxyUrl = await this.resolveAuthProxyUrl();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
target: proxyUrl,
|
target: await this.resolveAuthProxyUrl(),
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
timeout,
|
timeout,
|
||||||
headers: {
|
headers: {
|
||||||
@ -110,32 +102,22 @@ export class ContextHandler {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensurePort(): Promise<number> {
|
|
||||||
if (!this.proxyPort) {
|
|
||||||
this.proxyPort = await getFreePort();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.proxyPort;
|
|
||||||
}
|
|
||||||
|
|
||||||
async ensureServer() {
|
async ensureServer() {
|
||||||
if (!this.kubeAuthProxy) {
|
if (!this.kubeAuthProxy) {
|
||||||
await this.ensurePort();
|
|
||||||
const proxyEnv = Object.assign({}, process.env);
|
const proxyEnv = Object.assign({}, process.env);
|
||||||
|
|
||||||
if (this.cluster.preferences.httpsProxy) {
|
if (this.cluster.preferences.httpsProxy) {
|
||||||
proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy;
|
proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy;
|
||||||
}
|
}
|
||||||
this.kubeAuthProxy = new KubeAuthProxy(this.cluster, this.proxyPort, proxyEnv);
|
this.kubeAuthProxy = new KubeAuthProxy(this.cluster, proxyEnv);
|
||||||
await this.kubeAuthProxy.run();
|
await this.kubeAuthProxy.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stopServer() {
|
stopServer() {
|
||||||
if (this.kubeAuthProxy) {
|
this.kubeAuthProxy?.exit();
|
||||||
this.kubeAuthProxy.exit();
|
this.kubeAuthProxy = undefined;
|
||||||
this.kubeAuthProxy = null;
|
this.apiTarget = undefined;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get proxyLastError(): string {
|
get proxyLastError(): string {
|
||||||
|
|||||||
@ -7,11 +7,10 @@ import * as LensExtensions from "../extensions/core-api";
|
|||||||
import { app, autoUpdater, dialog, ipcMain, powerMonitor } from "electron";
|
import { app, autoUpdater, dialog, ipcMain, powerMonitor } from "electron";
|
||||||
import { appName, isMac, productName } from "../common/vars";
|
import { appName, isMac, productName } from "../common/vars";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { LensProxy } from "./lens-proxy";
|
import { LensProxy } from "./proxy/lens-proxy";
|
||||||
import { WindowManager } from "./window-manager";
|
import { WindowManager } from "./window-manager";
|
||||||
import { ClusterManager } from "./cluster-manager";
|
import { ClusterManager } from "./cluster-manager";
|
||||||
import { shellSync } from "./shell-sync";
|
import { shellSync } from "./shell-sync";
|
||||||
import { getFreePort } from "./port";
|
|
||||||
import { mangleProxyEnv } from "./proxy-env";
|
import { mangleProxyEnv } from "./proxy-env";
|
||||||
import { registerFileProtocol } from "../common/register-protocol";
|
import { registerFileProtocol } from "../common/register-protocol";
|
||||||
import logger from "./logger";
|
import logger from "./logger";
|
||||||
@ -34,6 +33,7 @@ import { catalogEntityRegistry } from "../common/catalog";
|
|||||||
import { HotbarStore } from "../common/hotbar-store";
|
import { HotbarStore } from "../common/hotbar-store";
|
||||||
import { HelmRepoManager } from "./helm/helm-repo-manager";
|
import { HelmRepoManager } from "./helm/helm-repo-manager";
|
||||||
import { KubeconfigSyncManager } from "./catalog-sources";
|
import { KubeconfigSyncManager } from "./catalog-sources";
|
||||||
|
import { handleWsUpgrade } from "./proxy/ws-upgrade";
|
||||||
import configurePackages from "../common/configure-packages";
|
import configurePackages from "../common/configure-packages";
|
||||||
|
|
||||||
const workingDir = path.join(app.getPath("appData"), appName);
|
const workingDir = path.join(app.getPath("appData"), appName);
|
||||||
@ -120,45 +120,33 @@ app.on("ready", async () => {
|
|||||||
filesystemStore.load(),
|
filesystemStore.load(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
const lensProxy = LensProxy.createInstance(handleWsUpgrade);
|
||||||
logger.info("🔑 Getting free port for LensProxy server");
|
|
||||||
const proxyPort = await getFreePort();
|
|
||||||
|
|
||||||
// create cluster manager
|
ClusterManager.createInstance();
|
||||||
ClusterManager.createInstance(proxyPort);
|
KubeconfigSyncManager.createInstance().startSync();
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
dialog.showErrorBox("Lens Error", "Could not find a free port for the cluster proxy");
|
|
||||||
app.exit();
|
|
||||||
}
|
|
||||||
|
|
||||||
const clusterManager = ClusterManager.getInstance();
|
|
||||||
|
|
||||||
// create kubeconfig sync manager
|
|
||||||
KubeconfigSyncManager.createInstance().startSync(clusterManager.port);
|
|
||||||
|
|
||||||
// run proxy
|
|
||||||
try {
|
try {
|
||||||
logger.info("🔌 Starting LensProxy");
|
logger.info("🔌 Starting LensProxy");
|
||||||
// eslint-disable-next-line unused-imports/no-unused-vars-ts
|
await lensProxy.listen();
|
||||||
LensProxy.createInstance(clusterManager.port).listen();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Could not start proxy (127.0.0:${clusterManager.port}): ${error?.message}`);
|
dialog.showErrorBox("Lens Error", `Could not start proxy: ${error?.message || "unknown error"}`);
|
||||||
dialog.showErrorBox("Lens Error", `Could not start proxy (127.0.0:${clusterManager.port}): ${error?.message || "unknown error"}`);
|
|
||||||
app.exit();
|
app.exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// test proxy connection
|
// test proxy connection
|
||||||
try {
|
try {
|
||||||
logger.info("🔎 Testing LensProxy connection ...");
|
logger.info("🔎 Testing LensProxy connection ...");
|
||||||
const versionFromProxy = await getAppVersionFromProxyServer(clusterManager.port);
|
const versionFromProxy = await getAppVersionFromProxyServer(lensProxy.port);
|
||||||
|
|
||||||
if (getAppVersion() !== versionFromProxy) {
|
if (getAppVersion() !== versionFromProxy) {
|
||||||
logger.error(`Proxy server responded with invalid response`);
|
logger.error("Proxy server responded with invalid response");
|
||||||
}
|
app.exit();
|
||||||
|
} else {
|
||||||
logger.info("⚡ LensProxy connection OK");
|
logger.info("⚡ LensProxy connection OK");
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Checking proxy server connection failed", error);
|
logger.error(`🛑 LensProxy: failed connection test: ${error}`);
|
||||||
|
app.exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
const extensionDiscovery = ExtensionDiscovery.createInstance();
|
const extensionDiscovery = ExtensionDiscovery.createInstance();
|
||||||
@ -171,7 +159,7 @@ app.on("ready", async () => {
|
|||||||
const startHidden = process.argv.includes("--hidden") || (isMac && app.getLoginItemSettings().wasOpenedAsHidden);
|
const startHidden = process.argv.includes("--hidden") || (isMac && app.getLoginItemSettings().wasOpenedAsHidden);
|
||||||
|
|
||||||
logger.info("🖥️ Starting WindowManager");
|
logger.info("🖥️ Starting WindowManager");
|
||||||
const windowManager = WindowManager.createInstance(clusterManager.port);
|
const windowManager = WindowManager.createInstance();
|
||||||
|
|
||||||
installDeveloperTools();
|
installDeveloperTools();
|
||||||
|
|
||||||
|
|||||||
29
src/main/k8s-request.ts
Normal file
29
src/main/k8s-request.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import request, { RequestPromiseOptions } from "request-promise-native";
|
||||||
|
import { apiKubePrefix } from "../common/vars";
|
||||||
|
import { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api";
|
||||||
|
import { LensProxy } from "./proxy/lens-proxy";
|
||||||
|
import { Cluster } from "./cluster";
|
||||||
|
|
||||||
|
export async function k8sRequest<T = any>(cluster: Cluster, path: string, options: RequestPromiseOptions = {}): Promise<T> {
|
||||||
|
const kubeProxyUrl = `http://localhost:${LensProxy.getInstance().port}${apiKubePrefix}`;
|
||||||
|
|
||||||
|
options.headers ??= {};
|
||||||
|
options.json ??= true;
|
||||||
|
options.timeout ??= 30000;
|
||||||
|
options.headers.Host = `${cluster.id}.${new URL(kubeProxyUrl).host}`; // required in ClusterManager.getClusterForRequest()
|
||||||
|
|
||||||
|
return request(kubeProxyUrl + path, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMetrics(cluster: Cluster, prometheusPath: string, queryParams: IMetricsReqParams & { query: string }): Promise<any> {
|
||||||
|
const prometheusPrefix = cluster.preferences.prometheus?.prefix || "";
|
||||||
|
const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`;
|
||||||
|
|
||||||
|
return k8sRequest(cluster, metricsPath, {
|
||||||
|
timeout: 0,
|
||||||
|
resolveWithFullResponse: false,
|
||||||
|
json: true,
|
||||||
|
method: "POST",
|
||||||
|
form: queryParams,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -5,24 +5,30 @@ import type { Cluster } from "./cluster";
|
|||||||
import { Kubectl } from "./kubectl";
|
import { Kubectl } from "./kubectl";
|
||||||
import logger from "./logger";
|
import logger from "./logger";
|
||||||
import * as url from "url";
|
import * as url from "url";
|
||||||
|
import { getPortFrom } from "./utils/get-port";
|
||||||
|
|
||||||
export interface KubeAuthProxyLog {
|
export interface KubeAuthProxyLog {
|
||||||
data: string;
|
data: string;
|
||||||
error?: boolean; // stream=stderr
|
error?: boolean; // stream=stderr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startingServeRegex = /^starting to serve on (?<address>.+)/i;
|
||||||
|
|
||||||
export class KubeAuthProxy {
|
export class KubeAuthProxy {
|
||||||
public lastError: string;
|
public lastError: string;
|
||||||
|
|
||||||
|
public get port(): number {
|
||||||
|
return this._port;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _port: number;
|
||||||
protected cluster: Cluster;
|
protected cluster: Cluster;
|
||||||
protected env: NodeJS.ProcessEnv = null;
|
protected env: NodeJS.ProcessEnv = null;
|
||||||
protected proxyProcess: ChildProcess;
|
protected proxyProcess: ChildProcess;
|
||||||
protected port: number;
|
|
||||||
protected kubectl: Kubectl;
|
protected kubectl: Kubectl;
|
||||||
|
|
||||||
constructor(cluster: Cluster, port: number, env: NodeJS.ProcessEnv) {
|
constructor(cluster: Cluster, env: NodeJS.ProcessEnv) {
|
||||||
this.env = env;
|
this.env = env;
|
||||||
this.port = port;
|
|
||||||
this.cluster = cluster;
|
this.cluster = cluster;
|
||||||
this.kubectl = Kubectl.bundled();
|
this.kubectl = Kubectl.bundled();
|
||||||
}
|
}
|
||||||
@ -39,7 +45,7 @@ export class KubeAuthProxy {
|
|||||||
const proxyBin = await this.kubectl.getPath();
|
const proxyBin = await this.kubectl.getPath();
|
||||||
const args = [
|
const args = [
|
||||||
"proxy",
|
"proxy",
|
||||||
"-p", `${this.port}`,
|
"-p", "0",
|
||||||
"--kubeconfig", `${this.cluster.kubeConfigPath}`,
|
"--kubeconfig", `${this.cluster.kubeConfigPath}`,
|
||||||
"--context", `${this.cluster.contextName}`,
|
"--context", `${this.cluster.contextName}`,
|
||||||
"--accept-hosts", this.acceptHosts,
|
"--accept-hosts", this.acceptHosts,
|
||||||
@ -50,6 +56,7 @@ export class KubeAuthProxy {
|
|||||||
args.push("-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, { env: this.env, });
|
this.proxyProcess = spawn(proxyBin, args, { env: this.env, });
|
||||||
this.proxyProcess.on("error", (error) => {
|
this.proxyProcess.on("error", (error) => {
|
||||||
this.sendIpcLogMessage({ data: error.message, error: true });
|
this.sendIpcLogMessage({ data: error.message, error: true });
|
||||||
@ -61,20 +68,20 @@ export class KubeAuthProxy {
|
|||||||
this.exit();
|
this.exit();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.proxyProcess.stdout.on("data", (data) => {
|
|
||||||
let logItem = data.toString();
|
|
||||||
|
|
||||||
if (logItem.startsWith("Starting to serve on")) {
|
|
||||||
logItem = "Authentication proxy started\n";
|
|
||||||
}
|
|
||||||
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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this._port = await getPortFrom(this.proxyProcess.stdout, {
|
||||||
|
lineRegex: startingServeRegex,
|
||||||
|
onFind: () => this.sendIpcLogMessage({ data: "Authentication proxy started\n" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.proxyProcess.stdout.on("data", (data: any) => {
|
||||||
|
this.sendIpcLogMessage({ data: data.toString() });
|
||||||
|
});
|
||||||
|
|
||||||
return waitUntilUsed(this.port, 500, 10000);
|
return waitUntilUsed(this.port, 500, 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,7 +103,7 @@ export class KubeAuthProxy {
|
|||||||
return errorMsg;
|
return errorMsg;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async sendIpcLogMessage(res: KubeAuthProxyLog) {
|
protected sendIpcLogMessage(res: KubeAuthProxyLog) {
|
||||||
const channel = `kube-auth:${this.cluster.id}`;
|
const channel = `kube-auth:${this.cluster.id}`;
|
||||||
|
|
||||||
logger.info(`[KUBE-AUTH]: out-channel "${channel}"`, { ...res, meta: this.cluster.getMeta() });
|
logger.info(`[KUBE-AUTH]: out-channel "${channel}"`, { ...res, meta: this.cluster.getMeta() });
|
||||||
|
|||||||
@ -6,12 +6,13 @@ import path from "path";
|
|||||||
import fs from "fs-extra";
|
import fs from "fs-extra";
|
||||||
import { dumpConfigYaml, loadConfig } from "../common/kube-helpers";
|
import { dumpConfigYaml, loadConfig } from "../common/kube-helpers";
|
||||||
import logger from "./logger";
|
import logger from "./logger";
|
||||||
|
import { LensProxy } from "./proxy/lens-proxy";
|
||||||
|
|
||||||
export class KubeconfigManager {
|
export class KubeconfigManager {
|
||||||
protected configDir = app.getPath("temp");
|
protected configDir = app.getPath("temp");
|
||||||
protected tempFile: string = null;
|
protected tempFile: string = null;
|
||||||
|
|
||||||
constructor(protected cluster: Cluster, protected contextHandler: ContextHandler, protected port: number) { }
|
constructor(protected cluster: Cluster, protected contextHandler: ContextHandler) { }
|
||||||
|
|
||||||
async getPath(): Promise<string> {
|
async getPath(): Promise<string> {
|
||||||
if (this.tempFile === undefined) {
|
if (this.tempFile === undefined) {
|
||||||
@ -46,15 +47,15 @@ export class KubeconfigManager {
|
|||||||
|
|
||||||
protected async init() {
|
protected async init() {
|
||||||
try {
|
try {
|
||||||
await this.contextHandler.ensurePort();
|
await this.contextHandler.ensureServer();
|
||||||
this.tempFile = await this.createProxyKubeconfig();
|
this.tempFile = await this.createProxyKubeconfig();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Failed to created temp config for auth-proxy`, { err });
|
logger.error(`Failed to created temp config for auth-proxy`, { err });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected resolveProxyUrl() {
|
get resolveProxyUrl() {
|
||||||
return `http://127.0.0.1:${this.port}/${this.cluster.id}`;
|
return `http://127.0.0.1:${LensProxy.getInstance().port}/${this.cluster.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -71,7 +72,7 @@ export class KubeconfigManager {
|
|||||||
clusters: [
|
clusters: [
|
||||||
{
|
{
|
||||||
name: contextName,
|
name: contextName,
|
||||||
server: this.resolveProxyUrl(),
|
server: this.resolveProxyUrl,
|
||||||
skipTLSVerify: undefined,
|
skipTLSVerify: undefined,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
import net, { AddressInfo } from "net";
|
|
||||||
import logger from "./logger";
|
|
||||||
|
|
||||||
// todo: check https://github.com/http-party/node-portfinder ?
|
|
||||||
|
|
||||||
export async function getFreePort(): Promise<number> {
|
|
||||||
logger.debug("Lookup new free port..");
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const server = net.createServer();
|
|
||||||
|
|
||||||
server.unref();
|
|
||||||
server.on("listening", () => {
|
|
||||||
const port = (server.address() as AddressInfo).port;
|
|
||||||
|
|
||||||
server.close(() => resolve(port));
|
|
||||||
logger.debug(`New port found: ${port}`);
|
|
||||||
});
|
|
||||||
server.on("error", error => {
|
|
||||||
logger.error(`Can't resolve new port: "${error}"`);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
server.listen({ host: "127.0.0.1", port: 0 });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
import { EventEmitter } from "events";
|
|
||||||
import { getFreePort } from "./port";
|
|
||||||
|
|
||||||
let newPort = 0;
|
|
||||||
|
|
||||||
jest.mock("net", () => {
|
|
||||||
return {
|
|
||||||
createServer() {
|
|
||||||
return new class MockServer extends EventEmitter {
|
|
||||||
listen = jest.fn(() => {
|
|
||||||
this.emit("listening");
|
|
||||||
|
|
||||||
return this;
|
|
||||||
});
|
|
||||||
address = () => {
|
|
||||||
newPort = Math.round(Math.random() * 10000);
|
|
||||||
|
|
||||||
return {
|
|
||||||
port: newPort
|
|
||||||
};
|
|
||||||
};
|
|
||||||
unref = jest.fn();
|
|
||||||
close = jest.fn(cb => cb());
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getFreePort", () => {
|
|
||||||
it("finds the next free port", async () => {
|
|
||||||
return expect(getFreePort()).resolves.toEqual(newPort);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
2
src/main/proxy/index.ts
Normal file
2
src/main/proxy/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
// Don't export the contents here
|
||||||
|
// It will break the extension webpack
|
||||||
@ -3,45 +3,30 @@ import http from "http";
|
|||||||
import spdy from "spdy";
|
import spdy from "spdy";
|
||||||
import httpProxy from "http-proxy";
|
import httpProxy from "http-proxy";
|
||||||
import url from "url";
|
import url from "url";
|
||||||
import * as WebSocket from "ws";
|
import { apiPrefix, apiKubePrefix } from "../../common/vars";
|
||||||
import { apiPrefix, apiKubePrefix } from "../common/vars";
|
import { Router } from "../router";
|
||||||
import { Router } from "./router";
|
import { ContextHandler } from "../context-handler";
|
||||||
import { ContextHandler } from "./context-handler";
|
import logger from "../logger";
|
||||||
import logger from "./logger";
|
import { Singleton } from "../../common/utils";
|
||||||
import { NodeShellSession, LocalShellSession } from "./shell-session";
|
import { ClusterManager } from "../cluster-manager";
|
||||||
import { Singleton } from "../common/utils";
|
|
||||||
import { ClusterManager } from "./cluster-manager";
|
type WSUpgradeHandler = (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => void;
|
||||||
|
|
||||||
export class LensProxy extends Singleton {
|
export class LensProxy extends Singleton {
|
||||||
protected origin: string;
|
protected origin: string;
|
||||||
protected proxyServer: http.Server;
|
protected proxyServer: http.Server;
|
||||||
protected router: Router;
|
protected router = new Router();
|
||||||
protected closed = false;
|
protected closed = false;
|
||||||
protected retryCounters = new Map<string, number>();
|
protected retryCounters = new Map<string, number>();
|
||||||
|
|
||||||
constructor(protected port: number) {
|
public port: number;
|
||||||
|
|
||||||
|
constructor(handleWsUpgrade: WSUpgradeHandler) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.origin = `http://localhost:${port}`;
|
|
||||||
this.router = new Router();
|
|
||||||
}
|
|
||||||
|
|
||||||
listen(port = this.port): this {
|
|
||||||
this.proxyServer = this.buildCustomProxy().listen(port, "127.0.0.1");
|
|
||||||
logger.info(`[LENS-PROXY]: Proxy server has started at ${this.origin}`);
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
logger.info("Closing proxy server");
|
|
||||||
this.proxyServer.close();
|
|
||||||
this.closed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected buildCustomProxy(): http.Server {
|
|
||||||
const proxy = this.createProxy();
|
const proxy = this.createProxy();
|
||||||
const spdyProxy = spdy.createServer({
|
|
||||||
|
this.proxyServer = spdy.createServer({
|
||||||
spdy: {
|
spdy: {
|
||||||
plain: true,
|
plain: true,
|
||||||
protocols: ["http/1.1", "spdy/3.1"]
|
protocols: ["http/1.1", "spdy/3.1"]
|
||||||
@ -50,18 +35,51 @@ export class LensProxy extends Singleton {
|
|||||||
this.handleRequest(proxy, req, res);
|
this.handleRequest(proxy, req, res);
|
||||||
});
|
});
|
||||||
|
|
||||||
spdyProxy.on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => {
|
this.proxyServer
|
||||||
|
.on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => {
|
||||||
if (req.url.startsWith(`${apiPrefix}?`)) {
|
if (req.url.startsWith(`${apiPrefix}?`)) {
|
||||||
this.handleWsUpgrade(req, socket, head);
|
handleWsUpgrade(req, socket, head);
|
||||||
} else {
|
} else {
|
||||||
this.handleProxyUpgrade(proxy, req, socket, head);
|
this.handleProxyUpgrade(proxy, req, socket, head);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
spdyProxy.on("error", (err) => {
|
}
|
||||||
logger.error("proxy error", err);
|
|
||||||
|
/**
|
||||||
|
* Starts the lens proxy.
|
||||||
|
* @resolves After the server is listening
|
||||||
|
* @rejects if there is an error before that happens
|
||||||
|
*/
|
||||||
|
listen(): Promise<void> {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
this.proxyServer.listen(0, "127.0.0.1");
|
||||||
|
|
||||||
|
this.proxyServer
|
||||||
|
.once("listening", () => {
|
||||||
|
this.proxyServer.removeAllListeners("error"); // don't reject the promise
|
||||||
|
|
||||||
|
const { address, port } = this.proxyServer.address() as net.AddressInfo;
|
||||||
|
|
||||||
|
logger.info(`[LENS-PROXY]: Proxy server has started at ${address}:${port}`);
|
||||||
|
|
||||||
|
this.proxyServer.on("error", (error) => {
|
||||||
|
logger.info(`[LENS-PROXY]: Subsequent error: ${error}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
return spdyProxy;
|
this.port = port;
|
||||||
|
resolve();
|
||||||
|
})
|
||||||
|
.once("error", (error) => {
|
||||||
|
logger.info(`[LENS-PROXY]: Proxy server failed to start: ${error}`);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
logger.info("Closing proxy server");
|
||||||
|
this.proxyServer.close();
|
||||||
|
this.closed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async handleProxyUpgrade(proxy: httpProxy, req: http.IncomingMessage, socket: net.Socket, head: Buffer) {
|
protected async handleProxyUpgrade(proxy: httpProxy, req: http.IncomingMessage, socket: net.Socket, head: Buffer) {
|
||||||
@ -166,21 +184,6 @@ export class LensProxy extends Singleton {
|
|||||||
return proxy;
|
return proxy;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected createWsListener(): WebSocket.Server {
|
|
||||||
const ws = new WebSocket.Server({ noServer: true });
|
|
||||||
|
|
||||||
return ws.on("connection", ((socket: WebSocket, req: http.IncomingMessage) => {
|
|
||||||
const cluster = ClusterManager.getInstance().getClusterForRequest(req);
|
|
||||||
const nodeParam = url.parse(req.url, true).query["node"]?.toString();
|
|
||||||
const shell = nodeParam
|
|
||||||
? new NodeShellSession(socket, cluster, nodeParam)
|
|
||||||
: new LocalShellSession(socket, cluster);
|
|
||||||
|
|
||||||
shell.open()
|
|
||||||
.catch(error => logger.error(`[SHELL-SESSION]: failed to open: ${error}`, { error }));
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ContextHandler): Promise<httpProxy.ServerOptions> {
|
protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ContextHandler): Promise<httpProxy.ServerOptions> {
|
||||||
if (req.url.startsWith(apiKubePrefix)) {
|
if (req.url.startsWith(apiKubePrefix)) {
|
||||||
delete req.headers.authorization;
|
delete req.headers.authorization;
|
||||||
@ -211,12 +214,4 @@ export class LensProxy extends Singleton {
|
|||||||
}
|
}
|
||||||
this.router.route(cluster, req, res);
|
this.router.route(cluster, req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async handleWsUpgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer) {
|
|
||||||
const wsServer = this.createWsListener();
|
|
||||||
|
|
||||||
wsServer.handleUpgrade(req, socket, head, (con) => {
|
|
||||||
wsServer.emit("connection", con, req);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
36
src/main/proxy/ws-upgrade.ts
Normal file
36
src/main/proxy/ws-upgrade.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* This file is here so that the "../shell-session" import can be injected into
|
||||||
|
* LensProxy at creation time. So that the `pty.node` extension isn't loaded
|
||||||
|
* into Lens Extension webpack bundle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as WebSocket from "ws";
|
||||||
|
import http from "http";
|
||||||
|
import net from "net";
|
||||||
|
import url from "url";
|
||||||
|
import { NodeShellSession, LocalShellSession } from "../shell-session";
|
||||||
|
import { ClusterManager } from "../cluster-manager";
|
||||||
|
import logger from "../logger";
|
||||||
|
|
||||||
|
function createWsListener(): WebSocket.Server {
|
||||||
|
const ws = new WebSocket.Server({ noServer: true });
|
||||||
|
|
||||||
|
return ws.on("connection", ((socket: WebSocket, req: http.IncomingMessage) => {
|
||||||
|
const cluster = ClusterManager.getInstance().getClusterForRequest(req);
|
||||||
|
const nodeParam = url.parse(req.url, true).query["node"]?.toString();
|
||||||
|
const shell = nodeParam
|
||||||
|
? new NodeShellSession(socket, cluster, nodeParam)
|
||||||
|
: new LocalShellSession(socket, cluster);
|
||||||
|
|
||||||
|
shell.open()
|
||||||
|
.catch(error => logger.error(`[SHELL-SESSION]: failed to open: ${error}`, { error }));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleWsUpgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer) {
|
||||||
|
const wsServer = createWsListener();
|
||||||
|
|
||||||
|
wsServer.handleUpgrade(req, socket, head, (con) => {
|
||||||
|
wsServer.emit("connection", con, req);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { LensApi } from "../lens-api";
|
|||||||
import { Cluster, ClusterMetadataKey } from "../cluster";
|
import { Cluster, ClusterMetadataKey } from "../cluster";
|
||||||
import { ClusterPrometheusMetadata } from "../../common/cluster-store";
|
import { ClusterPrometheusMetadata } from "../../common/cluster-store";
|
||||||
import logger from "../logger";
|
import logger from "../logger";
|
||||||
|
import { getMetrics } from "../k8s-request";
|
||||||
|
|
||||||
export type IMetricsQuery = string | string[] | {
|
export type IMetricsQuery = string | string[] | {
|
||||||
[metricName: string]: string;
|
[metricName: string]: string;
|
||||||
@ -22,7 +23,7 @@ async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPa
|
|||||||
async function loadMetricHelper(): Promise<any> {
|
async function loadMetricHelper(): Promise<any> {
|
||||||
for (const [attempt, lastAttempt] of ATTEMPTS.entries()) { // retry
|
for (const [attempt, lastAttempt] of ATTEMPTS.entries()) { // retry
|
||||||
try {
|
try {
|
||||||
return await cluster.getMetrics(prometheusPath, { query, ...queryParams });
|
return await getMetrics(cluster, prometheusPath, { query, ...queryParams });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (lastAttempt || (error?.statusCode >= 400 && error?.statusCode < 500)) {
|
if (lastAttempt || (error?.statusCode >= 400 && error?.statusCode < 500)) {
|
||||||
logger.error("[Metrics]: metrics not available", { error });
|
logger.error("[Metrics]: metrics not available", { error });
|
||||||
|
|||||||
@ -2,48 +2,58 @@ import { LensApiRequest } from "../router";
|
|||||||
import { LensApi } from "../lens-api";
|
import { LensApi } from "../lens-api";
|
||||||
import { spawn, ChildProcessWithoutNullStreams } from "child_process";
|
import { spawn, ChildProcessWithoutNullStreams } from "child_process";
|
||||||
import { Kubectl } from "../kubectl";
|
import { Kubectl } from "../kubectl";
|
||||||
import { getFreePort } from "../port";
|
|
||||||
import { shell } from "electron";
|
import { shell } from "electron";
|
||||||
import * as tcpPortUsed from "tcp-port-used";
|
import * as tcpPortUsed from "tcp-port-used";
|
||||||
import logger from "../logger";
|
import logger from "../logger";
|
||||||
|
import { getPortFrom } from "../utils/get-port";
|
||||||
|
|
||||||
|
interface PortForwardArgs {
|
||||||
|
clusterId: string;
|
||||||
|
kind: string;
|
||||||
|
namespace: string;
|
||||||
|
name: string;
|
||||||
|
port: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const internalPortRegex = /^forwarding from (?<address>.+) ->/i;
|
||||||
|
|
||||||
class PortForward {
|
class PortForward {
|
||||||
public static portForwards: PortForward[] = [];
|
public static portForwards: PortForward[] = [];
|
||||||
|
|
||||||
static getPortforward(forward: {clusterId: string; kind: string; name: string; namespace: string; port: string}) {
|
static getPortforward(forward: PortForwardArgs) {
|
||||||
return PortForward.portForwards.find((pf) => {
|
return PortForward.portForwards.find((pf) => (
|
||||||
return (
|
|
||||||
pf.clusterId == forward.clusterId &&
|
pf.clusterId == forward.clusterId &&
|
||||||
pf.kind == forward.kind &&
|
pf.kind == forward.kind &&
|
||||||
pf.name == forward.name &&
|
pf.name == forward.name &&
|
||||||
pf.namespace == forward.namespace &&
|
pf.namespace == forward.namespace &&
|
||||||
pf.port == forward.port
|
pf.port == forward.port
|
||||||
);
|
));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public clusterId: string;
|
|
||||||
public process: ChildProcessWithoutNullStreams;
|
public process: ChildProcessWithoutNullStreams;
|
||||||
public kubeConfig: string;
|
public clusterId: string;
|
||||||
public kind: string;
|
public kind: string;
|
||||||
public namespace: string;
|
public namespace: string;
|
||||||
public name: string;
|
public name: string;
|
||||||
public port: string;
|
public port: string;
|
||||||
public localPort: number;
|
public internalPort?: number;
|
||||||
|
|
||||||
constructor(obj: any) {
|
constructor(public kubeConfig: string, args: PortForwardArgs) {
|
||||||
Object.assign(this, obj);
|
this.clusterId = args.clusterId;
|
||||||
|
this.kind = args.kind;
|
||||||
|
this.namespace = args.namespace;
|
||||||
|
this.name = args.name;
|
||||||
|
this.port = args.port;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async start() {
|
public async start() {
|
||||||
this.localPort = await getFreePort();
|
|
||||||
const kubectlBin = await Kubectl.bundled().getPath();
|
const kubectlBin = await Kubectl.bundled().getPath();
|
||||||
const args = [
|
const args = [
|
||||||
"--kubeconfig", this.kubeConfig,
|
"--kubeconfig", this.kubeConfig,
|
||||||
"port-forward",
|
"port-forward",
|
||||||
"-n", this.namespace,
|
"-n", this.namespace,
|
||||||
`${this.kind}/${this.name}`,
|
`${this.kind}/${this.name}`,
|
||||||
`${this.localPort}:${this.port}`
|
`:${this.port}`
|
||||||
];
|
];
|
||||||
|
|
||||||
this.process = spawn(kubectlBin, args, {
|
this.process = spawn(kubectlBin, args, {
|
||||||
@ -58,8 +68,12 @@ class PortForward {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.internalPort = await getPortFrom(this.process.stdout, {
|
||||||
|
lineRegex: internalPortRegex,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await tcpPortUsed.waitUntilUsed(this.localPort, 500, 15000);
|
await tcpPortUsed.waitUntilUsed(this.internalPort, 500, 15000);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -70,7 +84,14 @@ class PortForward {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public open() {
|
public open() {
|
||||||
shell.openExternal(`http://localhost:${this.localPort}`);
|
shell.openExternal(`http://localhost:${this.internalPort}`)
|
||||||
|
.catch(error => logger.error(`[PORT-FORWARD]: failed to open external shell: ${error}`, {
|
||||||
|
clusterId: this.clusterId,
|
||||||
|
port: this.port,
|
||||||
|
kind: this.kind,
|
||||||
|
namespace: this.namespace,
|
||||||
|
name: this.name,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,13 +107,12 @@ class PortForwardRoute extends LensApi {
|
|||||||
|
|
||||||
if (!portForward) {
|
if (!portForward) {
|
||||||
logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`);
|
logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`);
|
||||||
portForward = new PortForward({
|
portForward = new PortForward(await cluster.getProxyKubeconfigPath(), {
|
||||||
clusterId: cluster.id,
|
clusterId: cluster.id,
|
||||||
kind: resourceType,
|
kind: resourceType,
|
||||||
namespace,
|
namespace,
|
||||||
name: resourceName,
|
name: resourceName,
|
||||||
port,
|
port,
|
||||||
kubeConfig: await cluster.getProxyKubeconfigPath()
|
|
||||||
});
|
});
|
||||||
const started = await portForward.start();
|
const started = await portForward.start();
|
||||||
|
|
||||||
|
|||||||
51
src/main/utils/get-port.ts
Normal file
51
src/main/utils/get-port.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { Readable } from "stream";
|
||||||
|
import URLParse from "url-parse";
|
||||||
|
|
||||||
|
interface GetPortArgs {
|
||||||
|
/**
|
||||||
|
* Should be case insensitive
|
||||||
|
* Must have a named matching group called `address`
|
||||||
|
*/
|
||||||
|
lineRegex: RegExp;
|
||||||
|
/**
|
||||||
|
* Called when the port is found
|
||||||
|
*/
|
||||||
|
onFind?: () => void;
|
||||||
|
/**
|
||||||
|
* Timeout for how long to wait for the port.
|
||||||
|
* Default: 5s
|
||||||
|
*/
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse lines from `stream` (assumes data comes in lines) to find the port
|
||||||
|
* which the source of the stream is watching on.
|
||||||
|
* @param stream A readable stream to match lines against
|
||||||
|
* @param args The args concerning the stream
|
||||||
|
* @returns A Promise for port number
|
||||||
|
*/
|
||||||
|
export function getPortFrom(stream: Readable, args: GetPortArgs): Promise<number> {
|
||||||
|
return new Promise<number>((resolve, reject) => {
|
||||||
|
const handler = (data: any) => {
|
||||||
|
const logItem: string = data.toString();
|
||||||
|
const match = logItem.match(args.lineRegex);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
// use unknown protocol so that there is no default port
|
||||||
|
const addr = new URLParse(`s://${match.groups.address.trim()}`);
|
||||||
|
|
||||||
|
args.onFind?.();
|
||||||
|
stream.off("data", handler);
|
||||||
|
clearTimeout(timeoutID);
|
||||||
|
resolve(+addr.port);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const timeoutID = setTimeout(() => {
|
||||||
|
stream.off("data", handler);
|
||||||
|
reject(new Error("failed to retrieve port from stream"));
|
||||||
|
}, args.timeout ?? 5000);
|
||||||
|
|
||||||
|
stream.on("data", handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import { ClusterFrameInfo, clusterFrameMap } from "../common/cluster-frames";
|
|||||||
import { IpcRendererNavigationEvents } from "../renderer/navigation/events";
|
import { IpcRendererNavigationEvents } from "../renderer/navigation/events";
|
||||||
import logger from "./logger";
|
import logger from "./logger";
|
||||||
import { isDevelopment, productName } from "../common/vars";
|
import { isDevelopment, productName } from "../common/vars";
|
||||||
|
import { LensProxy } from "./proxy/lens-proxy";
|
||||||
|
|
||||||
export class WindowManager extends Singleton {
|
export class WindowManager extends Singleton {
|
||||||
protected mainWindow: BrowserWindow;
|
protected mainWindow: BrowserWindow;
|
||||||
@ -20,16 +21,17 @@ export class WindowManager extends Singleton {
|
|||||||
|
|
||||||
@observable activeClusterId: ClusterId;
|
@observable activeClusterId: ClusterId;
|
||||||
|
|
||||||
constructor(protected proxyPort: number) {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
makeObservable(this);
|
makeObservable(this);
|
||||||
|
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
this.initMenu();
|
this.initMenu();
|
||||||
this.initTray();
|
this.initTray();
|
||||||
}
|
}
|
||||||
|
|
||||||
get mainUrl() {
|
get mainUrl() {
|
||||||
return `http://localhost:${this.proxyPort}`;
|
return `http://localhost:${LensProxy.getInstance().port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async initMainWindow(showSplash = true) {
|
async initMainWindow(showSplash = true) {
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export default migration({
|
|||||||
run(store) {
|
run(store) {
|
||||||
const hotbars: Hotbar[] = [];
|
const hotbars: Hotbar[] = [];
|
||||||
|
|
||||||
ClusterStore.getInstance().enabledClustersList.forEach((cluster: any) => {
|
ClusterStore.getInstance().clustersList.forEach((cluster: any) => {
|
||||||
const name = cluster.workspace;
|
const name = cluster.workspace;
|
||||||
|
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|||||||
@ -3,8 +3,8 @@
|
|||||||
$spacing: $padding * 2;
|
$spacing: $padding * 2;
|
||||||
|
|
||||||
.AceEditor {
|
.AceEditor {
|
||||||
min-height: 200px;
|
min-height: 600px;
|
||||||
max-height: 400px;
|
max-height: 600px;
|
||||||
border: 1px solid var(--colorVague);
|
border: 1px solid var(--colorVague);
|
||||||
border-radius: $radius;
|
border-radius: $radius;
|
||||||
|
|
||||||
@ -17,24 +17,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.Select {
|
|
||||||
.kube-context {
|
|
||||||
--flex-gap: #{$padding};
|
|
||||||
}
|
|
||||||
|
|
||||||
// todo: extract to component, merge with namespace-select.scss
|
|
||||||
&__placeholder {
|
|
||||||
width: 100%;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__control {
|
|
||||||
box-shadow: 0 0 0 1px $borderFaintColor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
code {
|
||||||
color: $pink-400;
|
color: $pink-400;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,56 +1,41 @@
|
|||||||
import "./add-cluster.scss";
|
import "./add-cluster.scss";
|
||||||
import os from "os";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { action, observable, runInAction, makeObservable } from "mobx";
|
import { action, observable, runInAction, makeObservable } from "mobx";
|
||||||
import { remote } from "electron";
|
|
||||||
import { KubeConfig } from "@kubernetes/client-node";
|
import { KubeConfig } from "@kubernetes/client-node";
|
||||||
import { Select, SelectOption } from "../select";
|
|
||||||
import { DropFileInput, Input } from "../input";
|
|
||||||
import { AceEditor } from "../ace-editor";
|
import { AceEditor } from "../ace-editor";
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
import { Icon } from "../icon";
|
import { loadConfig, splitConfig, validateKubeConfig } from "../../../common/kube-helpers";
|
||||||
import { kubeConfigDefaultPath, loadConfig, splitConfig, validateConfig, validateKubeConfig } from "../../../common/kube-helpers";
|
|
||||||
import { ClusterStore } from "../../../common/cluster-store";
|
import { ClusterStore } from "../../../common/cluster-store";
|
||||||
import { v4 as uuid } from "uuid";
|
import { v4 as uuid } from "uuid";
|
||||||
import { navigate } from "../../navigation";
|
import { navigate } from "../../navigation";
|
||||||
import { UserStore } from "../../../common/user-store";
|
import { UserStore } from "../../../common/user-store";
|
||||||
import { cssNames } from "../../utils";
|
|
||||||
import { Notifications } from "../notifications";
|
import { Notifications } from "../notifications";
|
||||||
import { Tab, Tabs } from "../tabs";
|
|
||||||
import { ExecValidationNotFoundError } from "../../../common/custom-errors";
|
import { ExecValidationNotFoundError } from "../../../common/custom-errors";
|
||||||
import { appEventBus } from "../../../common/event-bus";
|
import { appEventBus } from "../../../common/event-bus";
|
||||||
import { PageLayout } from "../layout/page-layout";
|
import { PageLayout } from "../layout/page-layout";
|
||||||
import { docsUrl } from "../../../common/vars";
|
import { docsUrl } from "../../../common/vars";
|
||||||
import { catalogURL } from "../+catalog";
|
import { catalogURL } from "../+catalog";
|
||||||
|
import { preferencesURL } from "../+preferences";
|
||||||
enum KubeConfigSourceTab {
|
import { Input } from "../input";
|
||||||
FILE = "file",
|
|
||||||
TEXT = "text"
|
|
||||||
}
|
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class AddCluster extends React.Component {
|
export class AddCluster extends React.Component {
|
||||||
@observable.ref kubeConfigLocal: KubeConfig;
|
@observable.ref kubeConfigLocal: KubeConfig;
|
||||||
@observable.ref error: React.ReactNode;
|
@observable.ref error: React.ReactNode;
|
||||||
|
|
||||||
@observable kubeContexts = observable.map<string, KubeConfig>(); // available contexts from kubeconfig-file or user-input
|
|
||||||
@observable selectedContexts = observable.array<string>();
|
|
||||||
@observable sourceTab = KubeConfigSourceTab.FILE;
|
|
||||||
@observable kubeConfigPath = "";
|
|
||||||
@observable customConfig = "";
|
@observable customConfig = "";
|
||||||
@observable proxyServer = "";
|
@observable proxyServer = "";
|
||||||
@observable isWaiting = false;
|
@observable isWaiting = false;
|
||||||
@observable showSettings = false;
|
@observable showSettings = false;
|
||||||
|
|
||||||
|
kubeContexts = observable.map<string, KubeConfig>();
|
||||||
|
|
||||||
constructor(props: object) {
|
constructor(props: object) {
|
||||||
super(props);
|
super(props);
|
||||||
makeObservable(this);
|
makeObservable(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
ClusterStore.getInstance().setActive(null);
|
|
||||||
this.setKubeConfig(UserStore.getInstance().kubeConfigPath);
|
|
||||||
appEventBus.emit({ name: "cluster-add", action: "start" });
|
appEventBus.emit({ name: "cluster-add", action: "start" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,53 +43,20 @@ export class AddCluster extends React.Component {
|
|||||||
UserStore.getInstance().markNewContextsAsSeen();
|
UserStore.getInstance().markNewContextsAsSeen();
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
|
||||||
setKubeConfig(filePath: string, { throwError = false } = {}) {
|
|
||||||
try {
|
|
||||||
this.kubeConfigLocal = loadConfig(filePath);
|
|
||||||
validateConfig(this.kubeConfigLocal);
|
|
||||||
this.refreshContexts();
|
|
||||||
this.kubeConfigPath = filePath;
|
|
||||||
UserStore.getInstance().kubeConfigPath = filePath; // save to store
|
|
||||||
} catch (err) {
|
|
||||||
if (!UserStore.getInstance().isDefaultKubeConfigPath) {
|
|
||||||
Notifications.error(
|
|
||||||
<div>Can't setup <code>{filePath}</code> as kubeconfig: {String(err)}</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (throwError) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
refreshContexts() {
|
refreshContexts() {
|
||||||
this.selectedContexts.clear();
|
|
||||||
this.kubeContexts.clear();
|
this.kubeContexts.clear();
|
||||||
|
|
||||||
switch (this.sourceTab) {
|
|
||||||
case KubeConfigSourceTab.FILE:
|
|
||||||
const contexts = this.getContexts(this.kubeConfigLocal);
|
|
||||||
|
|
||||||
this.kubeContexts.replace(contexts);
|
|
||||||
break;
|
|
||||||
case KubeConfigSourceTab.TEXT:
|
|
||||||
try {
|
try {
|
||||||
this.error = "";
|
this.error = "";
|
||||||
const contexts = this.getContexts(loadConfig(this.customConfig || "{}"));
|
const contexts = this.getContexts(loadConfig(this.customConfig || "{}"));
|
||||||
|
|
||||||
|
console.log(contexts);
|
||||||
|
|
||||||
this.kubeContexts.replace(contexts);
|
this.kubeContexts.replace(contexts);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.error = String(err);
|
this.error = String(err);
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.kubeContexts.size === 1) {
|
|
||||||
this.selectedContexts.push(this.kubeContexts.keys().next().value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getContexts(config: KubeConfig): Map<string, KubeConfig> {
|
getContexts(config: KubeConfig): Map<string, KubeConfig> {
|
||||||
@ -117,36 +69,14 @@ export class AddCluster extends React.Component {
|
|||||||
return contexts;
|
return contexts;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectKubeConfigDialog = async () => {
|
|
||||||
const { dialog, BrowserWindow } = remote;
|
|
||||||
const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
|
|
||||||
defaultPath: this.kubeConfigPath,
|
|
||||||
properties: ["openFile", "showHiddenFiles"],
|
|
||||||
message: `Select custom kubeconfig file`,
|
|
||||||
buttonLabel: `Use configuration`,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!canceled && filePaths.length) {
|
|
||||||
this.setKubeConfig(filePaths[0]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onDropKubeConfig = (files: File[]) => {
|
|
||||||
this.sourceTab = KubeConfigSourceTab.FILE;
|
|
||||||
this.setKubeConfig(files[0].path);
|
|
||||||
};
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
addClusters = (): void => {
|
addClusters = (): void => {
|
||||||
try {
|
try {
|
||||||
if (!this.selectedContexts.length) {
|
|
||||||
return void (this.error = "Please select at least one cluster context");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.error = "";
|
this.error = "";
|
||||||
this.isWaiting = true;
|
this.isWaiting = true;
|
||||||
appEventBus.emit({ name: "cluster-add", action: "click" });
|
appEventBus.emit({ name: "cluster-add", action: "click" });
|
||||||
const newClusters = this.selectedContexts.filter(context => {
|
const newClusters = Array.from(this.kubeContexts.keys()).filter(context => {
|
||||||
const kubeConfig = this.kubeContexts.get(context);
|
const kubeConfig = this.kubeContexts.get(context);
|
||||||
const error = validateKubeConfig(kubeConfig, context);
|
const error = validateKubeConfig(kubeConfig, context);
|
||||||
|
|
||||||
@ -162,9 +92,7 @@ export class AddCluster extends React.Component {
|
|||||||
}).map(context => {
|
}).map(context => {
|
||||||
const clusterId = uuid();
|
const clusterId = uuid();
|
||||||
const kubeConfig = this.kubeContexts.get(context);
|
const kubeConfig = this.kubeContexts.get(context);
|
||||||
const kubeConfigPath = this.sourceTab === KubeConfigSourceTab.FILE
|
const kubeConfigPath = ClusterStore.embedCustomKubeConfig(clusterId, kubeConfig); // save in app-files folder
|
||||||
? this.kubeConfigPath // save link to original kubeconfig in file-system
|
|
||||||
: ClusterStore.embedCustomKubeConfig(clusterId, kubeConfig); // save in app-files folder
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: clusterId,
|
id: clusterId,
|
||||||
@ -198,9 +126,8 @@ export class AddCluster extends React.Component {
|
|||||||
renderInfo() {
|
renderInfo() {
|
||||||
return (
|
return (
|
||||||
<p>
|
<p>
|
||||||
Add clusters by clicking the <span className="text-primary">Add Cluster</span> button.
|
Paste kubeconfig as a text from the clipboard to the textarea below.
|
||||||
You'll need to obtain a working kubeconfig for the cluster you want to add.
|
If you want to add clusters from kubeconfigs that exists on filesystem, please add those files (or folders) to kubeconfig sync via <a onClick={() => navigate(preferencesURL())}>Preferences</a>.
|
||||||
You can either browse it from the file system or paste it as a text from the clipboard.
|
|
||||||
Read more about adding clusters <a href={`${docsUrl}/clusters/adding-clusters/`} rel="noreferrer" target="_blank">here</a>.
|
Read more about adding clusters <a href={`${docsUrl}/clusters/adding-clusters/`} rel="noreferrer" target="_blank">here</a>.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
@ -209,144 +136,31 @@ export class AddCluster extends React.Component {
|
|||||||
renderKubeConfigSource() {
|
renderKubeConfigSource() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tabs onChange={this.onKubeConfigTabChange}>
|
|
||||||
<Tab
|
|
||||||
value={KubeConfigSourceTab.FILE}
|
|
||||||
label="Select kubeconfig file"
|
|
||||||
active={this.sourceTab == KubeConfigSourceTab.FILE}/>
|
|
||||||
<Tab
|
|
||||||
value={KubeConfigSourceTab.TEXT}
|
|
||||||
label="Paste as text"
|
|
||||||
active={this.sourceTab == KubeConfigSourceTab.TEXT}
|
|
||||||
/>
|
|
||||||
</Tabs>
|
|
||||||
{this.sourceTab === KubeConfigSourceTab.FILE && (
|
|
||||||
<div>
|
|
||||||
<div className="kube-config-select flex gaps align-center">
|
|
||||||
<Input
|
|
||||||
theme="round-black"
|
|
||||||
className="kube-config-path box grow"
|
|
||||||
value={this.kubeConfigPath}
|
|
||||||
onChange={v => this.kubeConfigPath = v}
|
|
||||||
onBlur={this.onKubeConfigInputBlur}
|
|
||||||
/>
|
|
||||||
{this.kubeConfigPath !== kubeConfigDefaultPath && (
|
|
||||||
<Icon
|
|
||||||
material="settings_backup_restore"
|
|
||||||
onClick={() => this.setKubeConfig(kubeConfigDefaultPath)}
|
|
||||||
tooltip="Reset"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Icon
|
|
||||||
material="folder"
|
|
||||||
onClick={this.selectKubeConfigDialog}
|
|
||||||
tooltip="Browse"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<small className="hint">
|
|
||||||
Pro-Tip: you can also drag-n-drop kubeconfig file to this area
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{this.sourceTab === KubeConfigSourceTab.TEXT && (
|
|
||||||
<div className="flex column">
|
<div className="flex column">
|
||||||
<AceEditor
|
<AceEditor
|
||||||
autoFocus
|
autoFocus
|
||||||
showGutter={false}
|
showGutter={false}
|
||||||
mode="yaml"
|
mode="yaml"
|
||||||
value={this.customConfig}
|
value={this.customConfig}
|
||||||
|
wrap={true}
|
||||||
onChange={value => {
|
onChange={value => {
|
||||||
this.customConfig = value;
|
this.customConfig = value;
|
||||||
this.refreshContexts();
|
this.refreshContexts();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<small className="hint">
|
|
||||||
Pro-Tip: paste kubeconfig to get available contexts
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderContextSelector() {
|
|
||||||
const allContexts = Array.from(this.kubeContexts.keys());
|
|
||||||
const placeholder = this.selectedContexts.length > 0
|
|
||||||
? <>Selected contexts: <b>{this.selectedContexts.length}</b></>
|
|
||||||
: "Select contexts";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Select
|
|
||||||
id="kubecontext-select" // todo: provide better mapping for integration tests (e.g. data-test-id="..")
|
|
||||||
placeholder={placeholder}
|
|
||||||
controlShouldRenderValue={false}
|
|
||||||
closeMenuOnSelect={false}
|
|
||||||
isOptionSelected={() => false}
|
|
||||||
options={allContexts}
|
|
||||||
formatOptionLabel={this.formatContextLabel}
|
|
||||||
noOptionsMessage={() => `No contexts available or they have been added already`}
|
|
||||||
onChange={({ value: ctx }: SelectOption<string>) => {
|
|
||||||
if (this.selectedContexts.includes(ctx)) {
|
|
||||||
this.selectedContexts.remove(ctx);
|
|
||||||
} else {
|
|
||||||
this.selectedContexts.push(ctx);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{this.selectedContexts.length > 0 && (
|
|
||||||
<small className="hint">
|
|
||||||
<span>Applying contexts:</span>{" "}
|
|
||||||
<code>{this.selectedContexts.join(", ")}</code>
|
|
||||||
</small>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
onKubeConfigInputBlur = () => {
|
|
||||||
const isChanged = this.kubeConfigPath !== UserStore.getInstance().kubeConfigPath;
|
|
||||||
|
|
||||||
if (isChanged) {
|
|
||||||
this.kubeConfigPath = this.kubeConfigPath.replace("~", os.homedir());
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.setKubeConfig(this.kubeConfigPath, { throwError: true });
|
|
||||||
} catch (err) {
|
|
||||||
this.setKubeConfig(UserStore.getInstance().kubeConfigPath); // revert to previous valid path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onKubeConfigTabChange = (tabId: KubeConfigSourceTab) => {
|
|
||||||
this.sourceTab = tabId;
|
|
||||||
this.error = "";
|
|
||||||
this.refreshContexts();
|
|
||||||
};
|
|
||||||
|
|
||||||
protected formatContextLabel = ({ value: context }: SelectOption<string>) => {
|
|
||||||
const isNew = UserStore.getInstance().newContexts.has(context);
|
|
||||||
const isSelected = this.selectedContexts.includes(context);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cssNames("kube-context flex gaps align-center", context)}>
|
|
||||||
<span>{context}</span>
|
|
||||||
{isNew && <Icon small material="fiber_new"/>}
|
|
||||||
{isSelected && <Icon small material="check" className="box right"/>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const submitDisabled = this.selectedContexts.length === 0;
|
const submitDisabled = this.kubeContexts.size === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropFileInput onDropFiles={this.onDropKubeConfig}>
|
|
||||||
<PageLayout className="AddClusters" showOnTop={true}>
|
<PageLayout className="AddClusters" showOnTop={true}>
|
||||||
<h2>Add Clusters from Kubeconfig</h2>
|
<h2>Add Clusters from Kubeconfig</h2>
|
||||||
{this.renderInfo()}
|
{this.renderInfo()}
|
||||||
{this.renderKubeConfigSource()}
|
{this.renderKubeConfigSource()}
|
||||||
{this.renderContextSelector()}
|
|
||||||
<div className="cluster-settings">
|
<div className="cluster-settings">
|
||||||
<a href="#" onClick={() => this.showSettings = !this.showSettings}>
|
<a href="#" onClick={() => this.showSettings = !this.showSettings}>
|
||||||
Proxy settings
|
Proxy settings
|
||||||
@ -374,15 +188,14 @@ export class AddCluster extends React.Component {
|
|||||||
<Button
|
<Button
|
||||||
primary
|
primary
|
||||||
disabled={submitDisabled}
|
disabled={submitDisabled}
|
||||||
label={this.selectedContexts.length < 2 ? "Add cluster" : "Add clusters"}
|
label={this.kubeContexts.keys.length < 2 ? "Add cluster" : "Add clusters"}
|
||||||
onClick={this.addClusters}
|
onClick={this.addClusters}
|
||||||
waiting={this.isWaiting}
|
waiting={this.isWaiting}
|
||||||
tooltip={submitDisabled ? "Select at least one cluster to add." : undefined}
|
tooltip={submitDisabled ? "Paste a valid kubeconfig." : undefined}
|
||||||
tooltipOverrideDisabled
|
tooltipOverrideDisabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</DropFileInput>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,13 +13,7 @@ import { CatalogEntity } from "../../api/catalog-entity";
|
|||||||
|
|
||||||
|
|
||||||
function getClusterForEntity(entity: CatalogEntity) {
|
function getClusterForEntity(entity: CatalogEntity) {
|
||||||
const cluster = ClusterStore.getInstance().getById(entity.metadata.uid);
|
return ClusterStore.getInstance().getById(entity.metadata.uid);
|
||||||
|
|
||||||
if (!cluster?.enabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cluster;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
entitySettingRegistry.add([
|
entitySettingRegistry.add([
|
||||||
|
|||||||
@ -31,10 +31,8 @@ export class RemoveClusterButton extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { cluster } = this.props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button accent onClick={this.confirmRemoveCluster} className="button-area" disabled={cluster.isManaged}>
|
<Button accent onClick={this.confirmRemoveCluster} className="button-area">
|
||||||
Remove Cluster
|
Remove Cluster
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
box-shadow: 0 0 0px 3px #ffffff;
|
box-shadow: 0 0 0px 3px var(--clusterMenuBackground), 0 0 0px 6px var(--textColorAccent);
|
||||||
transition: all 0s 0.8s;
|
transition: all 0s 0.8s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,6 +24,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 0 0px 3px var(--clusterMenuBackground), 0 0 0px 6px #ffffff30;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.isDragging {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
> .led {
|
> .led {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 3px;
|
left: 3px;
|
||||||
|
|||||||
@ -101,7 +101,7 @@ export class HotbarIcon extends React.Component<Props> {
|
|||||||
remove(item: CatalogEntity) {
|
remove(item: CatalogEntity) {
|
||||||
const hotbar = HotbarStore.getInstance();
|
const hotbar = HotbarStore.getInstance();
|
||||||
|
|
||||||
hotbar.removeFromHotbar(item);
|
hotbar.removeFromHotbar(item.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
onMenuItemClick(menuItem: CatalogEntityContextMenu) {
|
onMenuItemClick(menuItem: CatalogEntityContextMenu) {
|
||||||
|
|||||||
@ -44,15 +44,14 @@
|
|||||||
background: var(--layoutBackground);
|
background: var(--layoutBackground);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
position: relative;
|
position: relative;
|
||||||
transform: translateZ(0); // Remove flickering artifacts
|
|
||||||
|
|
||||||
&:hover {
|
&.isDraggingOver {
|
||||||
&:not(:empty) {
|
box-shadow: 0 0 0px 3px $clusterMenuBackground, 0 0 0px 6px #fff;
|
||||||
box-shadow: 0 0 0px 3px #ffffff1a;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.animating {
|
&.animating {
|
||||||
|
transform: translateZ(0); // Remove flickering artifacts
|
||||||
|
|
||||||
&:empty {
|
&:empty {
|
||||||
animation: shake .6s cubic-bezier(.36,.07,.19,.97) both;
|
animation: shake .6s cubic-bezier(.36,.07,.19,.97) both;
|
||||||
transform: translate3d(0, 0, 0);
|
transform: translate3d(0, 0, 0);
|
||||||
@ -61,48 +60,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:not(:empty) {
|
&:not(:empty) {
|
||||||
animation: outline 0.8s cubic-bezier(0.19, 1, 0.22, 1);
|
animation: outline 1s cubic-bezier(0.19, 1, 0.22, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.HotbarSelector {
|
|
||||||
height: 26px;
|
|
||||||
background-color: var(--layoutBackground);
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&:before {
|
|
||||||
content: " ";
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 20px;
|
|
||||||
background: linear-gradient(0deg, var(--clusterMenuBackground), transparent);
|
|
||||||
top: -20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Badge {
|
|
||||||
cursor: pointer;
|
|
||||||
background: var(--secondaryBackground);
|
|
||||||
width: 100%;
|
|
||||||
color: var(--settingsColor);
|
|
||||||
padding-top: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.Icon {
|
|
||||||
--size: 16px;
|
|
||||||
padding: 0 4px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: none;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.previous {
|
|
||||||
transform: rotateY(180deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shake {
|
@keyframes shake {
|
||||||
@ -130,6 +92,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
box-shadow: 0 0 0px 0px $clusterMenuBackground, 0 0 0px 3px #ffffff;
|
box-shadow: 0 0 0px 3px $clusterMenuBackground, 0 0 0px 6px #ffffff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,18 +1,15 @@
|
|||||||
import "./hotbar-menu.scss";
|
import "./hotbar-menu.scss";
|
||||||
import "./hotbar.commands";
|
import "./hotbar.commands";
|
||||||
|
|
||||||
import React, { ReactNode, useState } from "react";
|
import React, { HTMLAttributes, ReactNode, useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { HotbarIcon } from "./hotbar-icon";
|
import { HotbarIcon } from "./hotbar-icon";
|
||||||
import { cssNames, IClassName } from "../../utils";
|
import { cssNames, IClassName } from "../../utils";
|
||||||
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
|
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
|
||||||
import { HotbarItem, HotbarStore } from "../../../common/hotbar-store";
|
import { defaultHotbarCells, HotbarItem, HotbarStore } from "../../../common/hotbar-store";
|
||||||
import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity";
|
import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity";
|
||||||
import { Icon } from "../icon";
|
import { DragDropContext, Draggable, Droppable, DropResult } from "react-beautiful-dnd";
|
||||||
import { Badge } from "../badge";
|
import { HotbarSelector } from "./hotbar-selector";
|
||||||
import { CommandOverlay } from "../command-palette";
|
|
||||||
import { HotbarSwitchCommand } from "./hotbar-switch-command";
|
|
||||||
import { Tooltip, TooltipPosition } from "../tooltip";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: IClassName;
|
className?: IClassName;
|
||||||
@ -38,36 +35,67 @@ export class HotbarMenu extends React.Component<Props> {
|
|||||||
return item ? catalogEntityRegistry.items.find((entity) => entity.metadata.uid === item.entity.uid) : null;
|
return item ? catalogEntityRegistry.items.find((entity) => entity.metadata.uid === item.entity.uid) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
previous() {
|
onDragEnd(result: DropResult) {
|
||||||
HotbarStore.getInstance().switchToPrevious();
|
const { source, destination } = result;
|
||||||
|
|
||||||
|
if (!destination) { // Dropped outside of the list
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
next() {
|
const from = parseInt(source.droppableId);
|
||||||
HotbarStore.getInstance().switchToNext();
|
const to = parseInt(destination.droppableId);
|
||||||
}
|
|
||||||
|
|
||||||
openSelector() {
|
HotbarStore.getInstance().restackItems(from, to);
|
||||||
CommandOverlay.open(<HotbarSwitchCommand />);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderGrid() {
|
renderGrid() {
|
||||||
if (!this.hotbar.items.length) return;
|
|
||||||
|
|
||||||
return this.hotbar.items.map((item, index) => {
|
return this.hotbar.items.map((item, index) => {
|
||||||
const entity = this.getEntity(item);
|
const entity = this.getEntity(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotbarCell key={index} index={index}>
|
<Droppable droppableId={`${index}`} key={index}>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<HotbarCell
|
||||||
|
index={index}
|
||||||
|
key={entity ? entity.getId() : `cell${index}`}
|
||||||
|
innerRef={provided.innerRef}
|
||||||
|
className={cssNames({ isDraggingOver: snapshot.isDraggingOver })}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
>
|
||||||
{entity && (
|
{entity && (
|
||||||
|
<Draggable draggableId={item.entity.uid} key={item.entity.uid} index={0}>
|
||||||
|
{(provided, snapshot) => {
|
||||||
|
const style = {
|
||||||
|
zIndex: defaultHotbarCells - index,
|
||||||
|
position: "absolute",
|
||||||
|
...provided.draggableProps.style,
|
||||||
|
} as React.CSSProperties;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.entity.uid}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
<HotbarIcon
|
<HotbarIcon
|
||||||
key={index}
|
key={index}
|
||||||
index={index}
|
index={index}
|
||||||
entity={entity}
|
entity={entity}
|
||||||
isActive={this.isActive(entity)}
|
isActive={this.isActive(entity)}
|
||||||
onClick={() => entity.onRun(catalogEntityRunContext)}
|
onClick={() => entity.onRun(catalogEntityRunContext)}
|
||||||
|
className={cssNames({ isDragging: snapshot.isDragging })}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Draggable>
|
||||||
)}
|
)}
|
||||||
|
{provided.placeholder}
|
||||||
</HotbarCell>
|
</HotbarCell>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -76,48 +104,46 @@ export class HotbarMenu extends React.Component<Props> {
|
|||||||
const { className } = this.props;
|
const { className } = this.props;
|
||||||
const hotbarStore = HotbarStore.getInstance();
|
const hotbarStore = HotbarStore.getInstance();
|
||||||
const hotbar = hotbarStore.getActive();
|
const hotbar = hotbarStore.getActive();
|
||||||
const activeIndexDisplay = hotbarStore.activeHotbarIndex + 1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cssNames("HotbarMenu flex column", className)}>
|
<div className={cssNames("HotbarMenu flex column", className)}>
|
||||||
<div className="HotbarItems flex column gaps">
|
<div className="HotbarItems flex column gaps">
|
||||||
|
<DragDropContext onDragEnd={this.onDragEnd}>
|
||||||
{this.renderGrid()}
|
{this.renderGrid()}
|
||||||
|
</DragDropContext>
|
||||||
</div>
|
</div>
|
||||||
<div className="HotbarSelector flex align-center">
|
<HotbarSelector hotbar={hotbar}/>
|
||||||
<Icon material="play_arrow" className="previous box" onClick={() => this.previous()} />
|
|
||||||
<div className="box grow flex align-center">
|
|
||||||
<Badge id="hotbarIndex" small label={activeIndexDisplay} onClick={() => this.openSelector()} />
|
|
||||||
<Tooltip
|
|
||||||
targetId="hotbarIndex"
|
|
||||||
preferredPositions={TooltipPosition.TOP}
|
|
||||||
>
|
|
||||||
{hotbar.name}
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Icon material="play_arrow" className="next box" onClick={() => this.next()} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HotbarCellProps {
|
interface HotbarCellProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
index: number;
|
index: number;
|
||||||
|
innerRef?: React.LegacyRef<HTMLDivElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HotbarCell(props: HotbarCellProps) {
|
function HotbarCell({ innerRef, children, className, ...rest }: HotbarCellProps) {
|
||||||
const [animating, setAnimating] = useState(false);
|
const [animating, setAnimating] = useState(false);
|
||||||
const onAnimationEnd = () => { setAnimating(false); };
|
const onAnimationEnd = () => { setAnimating(false); };
|
||||||
const onClick = () => { setAnimating(true); };
|
const onClick = () => {
|
||||||
|
if (className.includes("isDraggingOver")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnimating(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cssNames("HotbarCell", { animating })}
|
className={cssNames("HotbarCell", { animating }, className)}
|
||||||
onAnimationEnd={onAnimationEnd}
|
onAnimationEnd={onAnimationEnd}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
ref={innerRef}
|
||||||
|
{...rest}
|
||||||
>
|
>
|
||||||
{props.children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
36
src/renderer/components/hotbar/hotbar-selector.scss
Normal file
36
src/renderer/components/hotbar/hotbar-selector.scss
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
.HotbarSelector {
|
||||||
|
height: 26px;
|
||||||
|
background-color: var(--layoutBackground);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: " ";
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 20px;
|
||||||
|
background: linear-gradient(0deg, var(--clusterMenuBackground), transparent);
|
||||||
|
top: -20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Badge {
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--secondaryBackground);
|
||||||
|
width: 100%;
|
||||||
|
color: var(--settingsColor);
|
||||||
|
padding-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Icon {
|
||||||
|
--size: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: none;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.previous {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/renderer/components/hotbar/hotbar-selector.tsx
Normal file
46
src/renderer/components/hotbar/hotbar-selector.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import "./hotbar-selector.scss";
|
||||||
|
import React from "react";
|
||||||
|
import { Icon } from "../icon";
|
||||||
|
import { Badge } from "../badge";
|
||||||
|
import { makeStyles, Tooltip } from "@material-ui/core";
|
||||||
|
import { Hotbar, HotbarStore } from "../../../common/hotbar-store";
|
||||||
|
import { CommandOverlay } from "../command-palette";
|
||||||
|
import { HotbarSwitchCommand } from "./hotbar-switch-command";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hotbar: Hotbar;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles(() => ({
|
||||||
|
arrow: {
|
||||||
|
color: "#222",
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
fontSize: 12,
|
||||||
|
backgroundColor: "#222",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
export function HotbarSelector({ hotbar }: Props) {
|
||||||
|
const store = HotbarStore.getInstance();
|
||||||
|
const activeIndexDisplay = store.activeHotbarIndex + 1;
|
||||||
|
const classes = useStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="HotbarSelector flex align-center">
|
||||||
|
<Icon material="play_arrow" className="previous box" onClick={() => store.switchToPrevious()} />
|
||||||
|
<div className="box grow flex align-center">
|
||||||
|
<Tooltip arrow title={hotbar.name} classes={classes}>
|
||||||
|
<Badge
|
||||||
|
id="hotbarIndex"
|
||||||
|
small
|
||||||
|
label={activeIndexDisplay}
|
||||||
|
onClick={() => CommandOverlay.open(<HotbarSwitchCommand />)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Icon material="play_arrow" className="next box" onClick={() => store.switchToNext()} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,20 +7,50 @@ import "@testing-library/jest-dom/extend-expect";
|
|||||||
import { MainLayoutHeader } from "../main-layout-header";
|
import { MainLayoutHeader } from "../main-layout-header";
|
||||||
import { Cluster } from "../../../../main/cluster";
|
import { Cluster } from "../../../../main/cluster";
|
||||||
import { ClusterStore } from "../../../../common/cluster-store";
|
import { ClusterStore } from "../../../../common/cluster-store";
|
||||||
|
import mockFs from "mock-fs";
|
||||||
|
|
||||||
const cluster: Cluster = new Cluster({
|
describe("<MainLayoutHeader />", () => {
|
||||||
|
let cluster: Cluster;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const mockOpts = {
|
||||||
|
"minikube-config.yml": JSON.stringify({
|
||||||
|
apiVersion: "v1",
|
||||||
|
clusters: [{
|
||||||
|
name: "minikube",
|
||||||
|
cluster: {
|
||||||
|
server: "https://192.168.64.3:8443",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
contexts: [{
|
||||||
|
context: {
|
||||||
|
cluster: "minikube",
|
||||||
|
user: "minikube",
|
||||||
|
},
|
||||||
|
name: "minikube",
|
||||||
|
}],
|
||||||
|
users: [{
|
||||||
|
name: "minikube",
|
||||||
|
}],
|
||||||
|
kind: "Config",
|
||||||
|
preferences: {},
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFs(mockOpts);
|
||||||
|
|
||||||
|
ClusterStore.createInstance();
|
||||||
|
|
||||||
|
cluster = new Cluster({
|
||||||
id: "foo",
|
id: "foo",
|
||||||
contextName: "minikube",
|
contextName: "minikube",
|
||||||
kubeConfigPath: "minikube-config.yml",
|
kubeConfigPath: "minikube-config.yml",
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("<MainLayoutHeader />", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
ClusterStore.createInstance();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
ClusterStore.resetInstance();
|
ClusterStore.resetInstance();
|
||||||
|
mockFs.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders w/o errors", () => {
|
it("renders w/o errors", () => {
|
||||||
|
|||||||
@ -24,7 +24,7 @@
|
|||||||
// covers whole app view area
|
// covers whole app view area
|
||||||
&.showOnTop {
|
&.showOnTop {
|
||||||
position: fixed !important; // allow to cover ClustersMenu
|
position: fixed !important; // allow to cover ClustersMenu
|
||||||
z-index: 3;
|
z-index: 13;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user